A travel platform starts with one Customer class. Booking uses it to store destinations, travel preferences, and previous reservations. Billing uses the same class for payment methods, currency, and transaction details. At first, sharing the model feels efficient.
The problems appear when the system grows. A billing change forces the booking team to update code it does not own. A property name means one thing to accountants and something else to travel agents. Database changes become coordinated releases, and each team needs knowledge of business rules outside its area.
The goal is not to create more layers for their own sake. The goal is to let each business area use the language and rules that make sense to its experts, while keeping communication between areas explicit and testable.
The Problem with One Universal Model
A shared model assumes that the same business term has one stable meaning across the entire organization. That is often untrue.
Consider the word Customer:
| Business area | What customer means |
|---|---|
| Booking | A traveler with preferences, destinations, and reservations |
| Billing | An account holder with payment instruments and currency settings |
| Support | A person with cases, messages, and service history |
Trying to merge these views creates a large object full of optional fields and unrelated behavior. The class becomes a compromise that accurately represents none of the business areas.
The deeper issue is language. Booking specialists and billing specialists use different terms because they solve different problems. When code forces both groups into one vocabulary, developers spend more time translating concepts and coordinating changes.
Step 1: Split the System by Business Language
Domain-Driven Design, commonly shortened to DDD, treats a change in business language as a strong signal that a new model boundary is needed.
A bounded context is a part of the system in which terms have one consistent meaning. Inside the Booking context, Customer can mean traveler. Inside Billing, it can mean payer. The two models do not need identical properties, methods, or storage structures.
Booking Context Billing Context
----------------- ----------------
Traveler Payer
Reservation Payment
TravelPackage PaymentMethod
Booking preferences Currency settings
ReservationConfirmed
|
v
Explicit integration contract
This design removes the requirement for a universal customer model. Each team owns its model and can evolve it independently.
The boundary is useful only when communication is explicit. Create a context map that records:
- Which bounded contexts exist
- Which team owns each context
- Which context initiates each interaction
- Which contracts cross the boundary
- Which terms require translation
- Which automated checks verify compatibility
For example, Booking may publish a ReservationConfirmed message containing only the information Billing needs to start payment processing. Billing should not receive Booking's entire internal aggregate.
Step 2: Model Identity and Values Correctly
A domain model usually needs both entities and value objects.
An entity has an identity that remains important even when its properties change. A reservation with the same identifier is still the same reservation after its travel dates are updated.
A value object has no independent identity. It is defined by its values and should normally be immutable. Money, an address, and geographic coordinates are typical examples.
C# records are a practical fit for value objects because records compare their values and can be created with immutable properties.
public sealed record Money(decimal Amount, string Currency);
public sealed record TravelerAddress(
string Country,
string City,
string Street);
A value object should represent one complete concept. Passing decimal amount and string currency through unrelated methods makes it easier to mix currencies or forget that both values belong together. Passing a Money object makes the meaning explicit.
Entities need identity-based equality. If an entity overrides Equals, it should also provide a compatible GetHashCode implementation so collections such as dictionaries and sets behave correctly.
Step 3: Protect Business Rules with Aggregates
A common failure in data-centric applications is allowing every child object to be loaded and changed independently. That can violate rules that involve the whole business operation.
A reservation and its reservation lines are a good example. A line belongs to one reservation and should not be treated as an independent transaction boundary. The reservation is the aggregate root, and callers modify its internal state through methods on that root.
public sealed class Reservation
{
private readonly List<ReservationLine> _lines = [];
public Guid Id { get; }
public IReadOnlyCollection<ReservationLine> Lines => _lines;
public Reservation(Guid id)
{
Id = id;
}
public void AddPackage(Guid packageId, Money price)
{
if (_lines.Any(line => line.PackageId == packageId))
{
throw new InvalidOperationException(
"The package is already part of this reservation.");
}
_lines.Add(new ReservationLine(packageId, price));
}
}
public sealed record ReservationLine(Guid PackageId, Money Price);
The important design choice is not the exact class shape. It is the transaction boundary:
- Load the complete aggregate needed for the operation
- Apply changes through the aggregate root
- Validate rules before persistence
- Save the aggregate as one consistent unit
If two callers update separate child objects without loading the aggregate, both changes may look valid locally while producing an invalid reservation globally.
Keep aggregates as small as the business rules allow. An aggregate is not a graph of every related database record. It is the smallest group of objects that must remain consistent in one operation.
Step 4: Keep Infrastructure Outside the Domain
A classic layered application often arranges code as Presentation, Business, and Data layers. This improves separation, but it can still make business code depend on database-oriented structures.
Onion Architecture reverses that pressure. Business abstractions stay in the center, while infrastructure details remain at the outside.
Outermost Layer
- Web API or user interface
- Database driver
- Operating system and cloud integrations
|
v
Application Services
- Commands
- Queries
- Command handlers
- Event handlers
|
v
Domain Services
- Repository interfaces
- Transaction abstractions
|
v
Domain Model
- Aggregates
- Entities
- Value objects
- Domain events
Dependencies point toward the business model. The domain does not know whether persistence uses a relational database, a document database, or a test double.
The outer layer connects interfaces to implementations through dependency injection. Replacing a database driver should not require rewriting aggregate behavior.
Step 5: Define Repositories Around Aggregates
A repository connects application operations to stored domain data. In a DDD-oriented design, define one repository interface per aggregate root.
The repository retrieves or creates aggregates. Business modifications remain methods on the aggregate itself.
public interface IUnitOfWork
{
Task StartAsync();
Task SaveChangesAsync();
Task CommitAsync();
Task RollbackAsync();
}
public interface IReservationRepository
{
IUnitOfWork UnitOfWork { get; }
Task<Reservation?> FindAsync(Guid reservationId);
Task AddAsync(Reservation reservation);
Task DeleteAsync(Reservation reservation);
}
This separation gives each operation an obvious home:
FindAsyncbelongs to the repository because it retrieves the aggregateAddPackagebelongs to the aggregate because it enforces a business ruleCommitAsyncbelongs to the unit of work because it controls the transaction
Avoid repository methods such as UpdateReservationPrice, SetTravelerName, or ChangePackage. Those methods move business behavior back into the persistence boundary and weaken the aggregate.
Step 6: Coordinate Transactions with a Unit of Work
Most business operations should stay inside one aggregate. Some workflows, however, need several repositories to participate in the same transaction.
The Unit of Work pattern gives repositories a shared transaction identity. Repositories connected to the same unit of work can save their pending changes together.
A command handler can start the transaction, load the required aggregates, execute domain behavior, save changes, and then commit.
public sealed record ConfirmReservationCommand(Guid ReservationId);
public sealed class ConfirmReservationHandler
{
private readonly IReservationRepository _reservations;
public ConfirmReservationHandler(
IReservationRepository reservations)
{
_reservations = reservations;
}
public async Task HandleAsync(
ConfirmReservationCommand command)
{
var unitOfWork = _reservations.UnitOfWork;
await unitOfWork.StartAsync();
try
{
var reservation = await _reservations.FindAsync(
command.ReservationId);
if (reservation is null)
{
throw new InvalidOperationException(
"Reservation was not found.");
}
reservation.Confirm();
await unitOfWork.SaveChangesAsync();
await unitOfWork.CommitAsync();
}
catch
{
await unitOfWork.RollbackAsync();
throw;
}
}
}
The handler coordinates the use case, but the aggregate owns the rule that decides whether confirmation is valid.
Step 7: Separate Writes from Reads with CQRS
Command Query Responsibility Segregation, or CQRS, uses different models for changing data and reading data.
The write side needs aggregates because it must protect business rules. The read side often needs only a projection shaped for the screen or API response.
Write request
-> Command
-> Command handler
-> Aggregate
-> Repository
-> Unit of Work
Read request
-> Query
-> Query handler
-> Read projection
-> Response model
Do not rebuild a large aggregate just to render a reservation list. A query can return a simple result with reservation number, destination, status, and total price.
This also prevents presentation requirements from leaking into the domain model. A table column added to an administrative page should not force a redesign of the reservation aggregate.
CQRS does not require separate databases. It first means separating responsibilities in code. Storage can be split later only when the system's needs justify it.
Step 8: Communicate Across Contexts with Domain Events
When a meaningful domain action occurs, the aggregate can record a domain event. For example, confirming a reservation may produce ReservationConfirmed.
An application-level event handler can react to that event and communicate with Billing. This keeps the reservation aggregate independent from billing APIs, message brokers, and transport details.
public sealed record ReservationConfirmed(
Guid ReservationId,
Guid PayerId,
Money Total);
public sealed class Reservation
{
private readonly List<object> _events = [];
public IReadOnlyCollection<object> Events => _events;
public void Confirm()
{
// Validate reservation state before confirmation.
_events.Add(new ReservationConfirmed(
Id,
PayerId,
CalculateTotal()));
}
}
A domain event represents something that happened in the business model. Event sourcing is a different decision. With event sourcing, stored events become the authoritative history used to rebuild state. Do not adopt event sourcing merely because the application publishes domain events.
Testing the Boundaries
Test the design at three levels:
- Aggregate tests verify business rules without a database or web server.
- Application tests verify that handlers load aggregates, save changes, and coordinate transactions.
- Contract tests verify that messages and interfaces shared between bounded contexts remain compatible.
A useful test scenario is to confirm that Booking can change its internal Traveler model without changing Billing, as long as the published integration contract remains stable.
Also test rollback behavior. When a handler fails after modifying an aggregate, the unit of work should not commit a partial operation.
Common Mistakes
Splitting by technical layer instead of business language
Creating separate API, service, and repository projects does not create bounded contexts. A bounded context is defined by a coherent business model and vocabulary.
Sharing domain entities between contexts
A shared Customer library recreates the original coupling. Share integration contracts when necessary, not internal entities.
Treating every table as an aggregate
Database relationships do not define transaction boundaries. Business consistency rules do.
Loading aggregates for every query
Aggregates are valuable for enforcing write-side rules. Read-only screens often need lightweight projections.
Putting infrastructure calls inside aggregates
An aggregate should not call a database, message broker, or external service directly. Record the domain event and let an outer handler perform the integration.
Using one transaction across bounded contexts
A bounded context should own its transaction boundary. Cross-context workflows should communicate through explicit contracts rather than pretending that all modules share one local transaction.
Implementation Checklist
- [ ] Identify words that have different meanings across business areas
- [ ] Define a bounded context for each coherent business language
- [ ] Record context ownership and integration relationships
- [ ] Keep internal domain models private to their contexts
- [ ] Model identity with entities and descriptive values with immutable value objects
- [ ] Define aggregates around business consistency boundaries
- [ ] Modify aggregate state through aggregate-root methods
- [ ] Place repository interfaces near the domain and implementations in the outer layer
- [ ] Use a unit of work when several repositories must share one transaction
- [ ] Separate write commands from read queries when their models differ
- [ ] Publish domain events instead of calling another context from an aggregate
- [ ] Test aggregate rules, application coordination, and cross-context contracts independently
Conclusion
A universal model looks simple only while the system and team are small. Once different business areas attach different meanings and rules to the same terms, that shared model becomes a coordination bottleneck.
Bounded contexts let each team model its own language. Aggregates protect consistency, repositories isolate persistence, a unit of work coordinates local transactions, CQRS separates write rules from read shapes, and domain events make cross-context communication explicit.
The result is not merely a different folder structure. It is a system in which business boundaries are visible in the code, and changes in one area are less likely to break another.