Modern enterprise applications often become difficult to maintain when business rules are scattered across services, controllers, and database logic. This is where Domain-Driven Design (DDD) helps by organizing software around the business domain and enforcing business rules directly inside the domain model.
In this article, we will build a simple School Management System using ASP.NET Core and Entity Framework Core while following Domain-Driven Design principles.
What is Domain-Driven Design (DDD)?
Domain-Driven Design is a software development approach where the application structure is centered around the business domain and business rules.
The primary goal of DDD is to create software models that closely represent real business processes and protect important business rules.
DDD encourages:
- Rich domain models
- Meaningful business terminology
- Protected invariants
- Encapsulation of business logic
- Clear separation of concerns
Important DDD Concepts Used in This Example
- Aggregate Root
- Entities
- Child Entities
- Repository Pattern
- Encapsulation
- Rich Domain Behavior
1. Domain Layer
The Domain Layer contains entities, business rules, and repository interfaces. This layer should remain independent from databases, frameworks, and UI components.
Student Entity (Aggregate Root)
The Student entity is the Aggregate Root. It controls all operations related to CourseEnrollment child entities.
namespace DDDDemoWithDotNet.Domain.Entities
{
public class Student
{
public int StudentID { get; private set; }
public string StudentName { get; private set; }
private readonly List<CourseEnrollment> _enrollmentsOfStudent =
new List<CourseEnrollment>();
public IReadOnlyCollection<CourseEnrollment> EnrollmentsOfStudent =>
_enrollmentsOfStudent;
private Student()
{
}
public Student(string studentName)
{
if (string.IsNullOrWhiteSpace(studentName))
throw new Exception("Name of the Student is required.");
this.StudentName = studentName;
}
public void EnrollInCourse(Course course)
{
if (_enrollmentsOfStudent.Any(x => x.CourseID == course.CourseID))
throw new Exception("Student is already enrolled in this course.");
_enrollmentsOfStudent.Add(
new CourseEnrollment(this.StudentID, course.CourseID));
}
}
}
Notice that properties use private setters and the child collection is private. This prevents external classes from directly modifying entity state.
CourseEnrollment Entity (Child Entity)
namespace DDDDemoWithDotNet.Domain.Entities
{
public class CourseEnrollment
{
public int CourseEnrollmentID { get; private set; }
public int CourseID { get; private set; }
public int StudentID { get; private set; }
private CourseEnrollment()
{
}
public CourseEnrollment(int studentID, int courseID)
{
if (studentID < 0)
{
throw new Exception("Student is required for enrollment.");
}
else if (courseID < 0)
{
throw new Exception("Course is required for enrollment.");
}
this.StudentID = studentID;
this.CourseID = courseID;
}
}
}
The CourseEnrollment entity cannot be directly manipulated outside the Student aggregate root.
Course Entity
namespace DDDDemoWithDotNet.Domain.Entities
{
public class Course
{
public int CourseID { get; private set; }
public string CourseName { get; private set; }
private Course()
{
}
public Course(string courseName)
{
this.CourseName = courseName;
}
}
}
Repository Interfaces
Repository interfaces are defined inside the Domain Layer while their implementations remain in the Infrastructure Layer.
IStudentRepository
namespace DDDDemoWithDotNet.Domain.Interfaces
{
public interface IStudentRepository
{
void Save(Student student);
Student? GetById(int studentID);
}
}
ICourseRepository
namespace DDDDemoWithDotNet.Domain.Interfaces
{
public interface ICourseRepository
{
void Save(Course student);
Course? GetById(int courseID);
}
}
2. Infrastructure Layer
The Infrastructure Layer handles Entity Framework Core, database access, and repository implementations.
SchoolDbContext
namespace DDDDemoWithDotNet.Infrastructure.Data
{
public class SchoolDbContext : DbContext
{
public DbSet<Student> Student { get; set; }
public DbSet<Course> Course { get; set; }
public SchoolDbContext(DbContextOptions<SchoolDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Student>()
.Property(e => e.StudentID)
.ValueGeneratedOnAdd();
modelBuilder.Entity<Course>()
.Property(e => e.CourseID)
.ValueGeneratedOnAdd();
modelBuilder.Entity<CourseEnrollment>()
.Property(e => e.CourseEnrollmentID)
.ValueGeneratedOnAdd();
}
}
}
StudentRepository
namespace DDDDemoWithDotNet.Infrastructure.Repositories
{
public class StudentRepository : IStudentRepository
{
private readonly SchoolDbContext _schoolDbContext;
public StudentRepository(SchoolDbContext schoolDbContext)
{
this._schoolDbContext = schoolDbContext;
}
public void Save(Student student)
{
if (student.StudentID == 0)
{
this._schoolDbContext.Student.Add(student);
}
else
{
this._schoolDbContext.Student.Update(student);
}
this._schoolDbContext.SaveChanges();
}
public Student? GetById(int studentID)
{
return this._schoolDbContext.Student
.Include(s => s.EnrollmentsOfStudent)
.FirstOrDefault(s => s.StudentID == studentID);
}
}
}
Notice that there is no repository for CourseEnrollment because child entities should only be managed through the Aggregate Root.
3. Application Layer
The Application Layer coordinates workflows and communicates between repositories and domain entities.
StudentService
namespace DDDDemoWithDotNet.Application.Services
{
public class StudentService
{
private readonly IStudentRepository _studentRepository;
private readonly ICourseRepository _courseRepository;
public StudentService(
IStudentRepository studentRepository,
ICourseRepository courseRepository)
{
this._studentRepository = studentRepository;
this._courseRepository = courseRepository;
}
public void RegisterStudent(string name)
{
var student = new Student(name);
this._studentRepository.Save(student);
}
public void EnrollStudent(int studentID, int courseID)
{
var student = this._studentRepository.GetById(studentID);
if (student == null)
{
throw new Exception("Student is not found.");
}
var course = this._courseRepository.GetById(courseID);
if (course == null)
{
throw new Exception("Course is not found.");
}
student.EnrollInCourse(course);
this._studentRepository.Save(student);
}
}
}
4. Presentation Layer (Web API)
The Presentation Layer exposes APIs and handles HTTP requests. Controllers should remain lightweight and should not contain business logic.
StudentsController
namespace DDDDemoWithDotNet.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class StudentsController : ControllerBase
{
private readonly StudentService _studentService;
private readonly CourseService _courseService;
public StudentsController(
StudentService studentService,
CourseService courseService)
{
this._studentService = studentService;
this._courseService = courseService;
}
[HttpPost("create-course")]
public IActionResult CreateCourse(string courseName)
{
this._courseService.CreateCourse(courseName);
return Ok("Course has been created.");
}
[HttpPost("register-student")]
public IActionResult RegisterStudent(string studentName)
{
this._studentService.RegisterStudent(studentName);
return Ok("Student has been registered.");
}
[HttpPost("enroll-student")]
public IActionResult Enroll(int studentID, int courseID)
{
try
{
this._studentService.EnrollStudent(studentID, courseID);
return Ok("Student has been enrolled in course.");
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}
}
Dependency Injection Configuration
builder.Services.AddDbContext<SchoolDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("StudentConn")));
builder.Services.AddScoped<IStudentRepository, StudentRepository>();
builder.Services.AddScoped<ICourseRepository, CourseRepository>();
builder.Services.AddScoped<StudentService>();
builder.Services.AddScoped<CourseService>();
appsettings.json
"ConnectionStrings": {
"StudentConn": "Server=localhost;Initial Catalog=MyDDD;Integrated Security=True;TrustServerCertificate=True;"
}
Advantages of Domain-Driven Design
- Better maintainability
- Rich domain behavior
- Strong encapsulation
- Clear separation of concerns
- Improved scalability
- Easier unit testing
- Business rules remain protected
Important Takeaway
Instead of directly creating CourseEnrollment records from repositories or controllers, all enrollment operations should go through the Student aggregate.
student.EnrollInCourse(course);
This ensures that business rules and aggregate consistency remain protected.
Conclusion
Domain-Driven Design helps organizations build scalable and maintainable enterprise applications by placing business logic at the center of the architecture.
In this article, we implemented Aggregate Roots, child entities, repository pattern, layered architecture, and rich domain behavior using ASP.NET Core and Entity Framework Core.
When implemented properly, DDD leads to cleaner codebases, better maintainability, and software that closely reflects real business processes.