Repositories and Application Services in Domain-Driven Design

Tying the Tactical Building Blocks Together

Software Design

Business rules and persistence code often end up in the same place. The code that enforces a rule also loads its own data and saves its own results, until the two concerns are hard to separate. Testing a business rule then requires a database that has nothing to do with the rule itself.

Repositories and application services are how domain-driven design separates them. Repositories give the domain a way to retrieve and store objects without any knowledge of the database. Application services coordinate each use case, loading what is needed, invoking the domain model, and persisting the result.

The previous articles built the domain model piece by piece, using an online learning platform as the running example. Entities and value objects carry domain concepts, aggregates enforce consistency boundaries, and domain events record what changed. This article adds the outer layer that connects those pieces to a running system.

The Deliberate Separation

On the learning platform, enrolling a student involves several steps. The enrollment must not already exist for this student and course. If it does not, a new enrollment is created and enroll() is called on it. The result needs to be stored and the resulting event needs to reach its handlers. The aggregate knows the rules and will enforce them. It does not know where enrollments come from or where they go when the work is done.

This is not a gap in the design. It is a deliberate separation. The domain model captures what the business cares about. Where objects come from and where they go is an infrastructure concern. If the aggregate needed to know about connection strings, transaction managers, or persistence frameworks, that knowledge would follow it everywhere, including into tests that only need to verify a business rule.

The Domain’s View of Persistence

A repository is an abstraction over persistence for a single aggregate type.1 The domain layer defines the interface, describing what operations it needs in language that reflects the domain. The infrastructure layer provides the implementation, the actual code that talks to the database.

On the learning platform, the domain defines an EnrollmentRepository with methods like findById and save. Whether the implementation stores enrollments in a relational database, a document store, or an in-memory map is not the domain’s concern. The dependency points inward. Infrastructure depends on the domain, never the other way around.

EnrollmentRepository interface in the domain layer, implemented in the infrastructure layer
EnrollmentRepository interface in the domain layer, implemented in the infrastructure layer

Only aggregate roots have repositories. There is no repository for Lesson entries inside a Course. Lessons are reached through the Course aggregate root, which is the only legitimate entry point to them. The repository boundary enforces the aggregate boundary.

Repository methods reflect domain language, not database capabilities. A method like findPublishedCourses() is a domain concept: it names what the caller needs. A generic findAll(filter) leaks the persistence model upward and forces the caller to know how filtering works. When the interface speaks the domain’s language, reading it tells you what the domain actually needs from persistence.

The name EnrollmentRepository follows the pattern name for clarity. In practice, the domain language should guide the interface name. A plural noun like Enrollments reads like a collection and is often more natural.

The same interface that production code uses can be implemented in memory for tests. The full use case runs without a database, without a container, without any infrastructure at all. The test sets up state directly, invokes the business operation, and asserts on the result. The separation between domain and infrastructure is not just a design principle. It is what makes this possible.

Coordinating a Use Case

An application service coordinates a single use case. It loads the right aggregate from the repository, calls a method on it, persists the result, and dispatches the events the aggregate recorded. That is the full scope of its responsibility.

On the learning platform, an enrollment use case method loads the relevant aggregate, calls enroll() on it, saves the updated state, and dispatches the EnrollmentCompleted event. There is no business logic here. The service does not decide whether the enrollment is valid. It does not check prerequisites or enforce any rules. The aggregate knows the rules. The service arranges for them to be exercised.

A well-designed application service method is short. When a conditional appears in the service, such as checking whether a student is eligible or deciding which path to take based on current state, it almost certainly belongs in the aggregate, not the service. The length of the application service is a signal. When it starts growing, something has drifted out of the domain model.

The application service also owns the transaction boundary. Everything that must succeed as a unit happens within the same transaction: loading the aggregate, changing its state, persisting it, and dispatching its events. This connects back to the Domain Events article: the internal collection pattern collects events on the aggregate during the command, and the service reads those events and dispatches them after the transaction commits.

A Use Case From Start to Finish

Enrolling a student ties every building block together. The service loads the enrollment aggregate through the repository. That step is the repository at work, hiding whether the enrollment already exists or needs to be created. Calling enroll() puts the aggregate to work: it enforces that a student cannot enroll twice and records EnrollmentCompleted when the enrollment succeeds. Persisting the updated aggregate writes it back through the repository. Dispatching the recorded events notifies every handler that registered interest in the completion.

Application service coordinating the enrollment use case across repository, aggregate, and event bus
Application service coordinating the enrollment use case across repository, aggregate, and event bus

Each step corresponds to a pattern from earlier in the series. The aggregate enforced the rule. The repository hid the persistence. The event carried what happened. The application service held it together.

Testing this use case means providing an in-memory implementation of EnrollmentRepository. The test sets up whatever state is needed, calls the service, and asserts on the repository contents and the dispatched events. No database, no framework infrastructure. The entire use case is verifiable in memory, and every architectural choice that seemed abstract in earlier articles earns its keep here.

What Belongs Where

Business logic belongs in the aggregate. Coordination belongs in the application service. Persistence details belong in the infrastructure. When something ends up in the wrong layer, the cost appears over time rather than immediately.

Business logic in the aggregate, coordination in the application service, persistence details in the infrastructure
Business logic in the aggregate, coordination in the application service, persistence details in the infrastructure

The most common mistake is business logic leaking into the application service. Rules that belong in the aggregate accumulate in the service, where they are harder to find, harder to test, and easier to bypass. The aggregate is no longer the authority on its own invariants.

Infrastructure leaking into the domain runs in the other direction. Persistence framework annotations on domain objects, base classes that come from the ORM, identifier types chosen to match what the database generates. Each is a small compromise that ties the domain model to a specific infrastructure choice. When that choice changes, the domain model changes with it.

The Matching Architecture article established that the core domain deserves a different level of investment than supporting or generic concerns. Keeping infrastructure out of the domain is what that investment looks like in practice. This is what the hexagonal architecture pattern describes: the domain at the center, infrastructure on the outside, dependencies pointing inward. The repository and application service are where it becomes concrete.

What Comes Next

The coordination layer is now in place. Aggregates enforce their invariants, repositories handle persistence, and application services tie each use case together. But some domain logic still has no clear home. Behavior that spans multiple aggregates does not belong to any single one of them, and placing it in the application service would mean putting domain logic in the wrong layer.

On the learning platform, what handles the logic that determines certificate eligibility? Checking completed lessons, passed assessments, and course requirements involves looking across multiple aggregates. Who is responsible for creating a course from an existing template, assembling its initial structure in one operation? The next article closes those gaps, introduces the remaining patterns, and shows the complete domain model as a coherent whole.


  1. Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003. ↩︎

This article is part of the Domain-Driven Design series

  1. 1. What Is Domain-Driven Design?
  2. 2. Subdomains and Bounded Contexts in Domain-Driven Design
  3. 3. Context Mapping in Domain-Driven Design
  4. 4. Integrating Bounded Contexts
  5. 5. Matching Architecture to the Subdomain
  6. 6. Entities and Value Objects in Domain-Driven Design
  7. 7. Aggregates in Domain-Driven Design
  8. 8. Domain Events in Domain-Driven Design
  9. 9. Repositories and Application Services in Domain-Driven Design
domain-driven-design