Skip to main content
Blog

Domain-Driven Design Example: Master Domain-Driven Design

#domaindrivendesign#softwarearchitecture#microservices#softwareengineering#cleancode

Explore a practical Domain-Driven Design example for fleet management. Learn core concepts like Bounded Contexts and Aggregates for scalable microservices.

John Pratt
John Pratt
May 6, 202613 min read
Creator labeled this content as AI-generated

Article Header Image

Teams often don't reach for a domain driven design example when the system is still small. They reach for it after a few years of growth, when every release feels riskier than it should.

The pattern is familiar. A product starts as a clean web app or service. Then more workflows arrive, more teams touch the code, and the business asks for exceptions that don't fit the original model. What used to be one “order” or one “account” becomes five competing meanings depending on who is speaking. The code still compiles, but the architecture stops helping.

That's where Domain-Driven Design earns its keep. It gives you a way to align software structure with business complexity, especially in cloud-native systems where teams need to ship independently without corrupting each other's models.

The Inevitable Complexity of Growing Software

A healthy codebase rarely turns into a mess overnight. It happens release by release.

A reporting feature needs billing data, so someone reaches across a module boundary. A support workflow needs a quick override, so a rule gets added in a controller. A new team joins and copies an existing pattern without understanding why it was there. Six months later, business logic lives in handlers, ORM hooks, stored procedures, and frontend validation. Nobody can say where the actual rule is.

The operational symptoms show up fast. A bug fix in invoice processing breaks account suspension. A schema change for shipping impacts search. New engineers need weeks to understand which layer owns what. If you've been trying to reduce that drag, this is also where adjacent practices like mastering Python dependency injection patterns help. Clear composition and clear domain boundaries reinforce each other.

What the team usually feels first

The first signal isn't “we need DDD.” It's usually one of these:

  • Changes feel unsafe: A small feature touches too many files and too many teams.
  • Language drifts: Product says “customer,” support says “account holder,” finance says “payer,” and the code mixes all three.
  • Ownership gets fuzzy: Nobody knows whether a rule belongs in the API layer, service layer, or database.
  • Technical debt becomes business debt: Delivery slows because every change needs coordination across unrelated areas. Teams dealing with that trade-off usually need a practical plan for managing technical debt, not just a cleanup sprint.

Software gets brittle when the code model no longer matches the business model.

That's the problem DDD addresses. Not “how do we write prettier classes,” but “how do we stop complexity from spreading across the whole system.”

Why simple layering stops working

Traditional layered architecture can work for CRUD-heavy systems. It starts to fail when the business has real rules, exceptions, and competing concepts. If your domain has scheduling constraints, compliance rules, inventory reservations, pricing logic, or policy calculations, an anemic model usually pushes that complexity outward into service code.

Then every change becomes a scavenger hunt.

DDD gives teams a different center of gravity. Instead of organizing primarily around technical layers, it organizes around domain meaning and explicit ownership of business logic. That shift is what makes large systems more understandable again.

The Core Language of Domain Driven Design

Domain-Driven Design was formally introduced by Eric Evans in his 2003 book, and its adoption grew with microservices because Bounded Contexts provide a natural way to define service boundaries in complex sectors such as finance, e-commerce, and healthcare, as described in Vaadin's overview of strategic DDD.

A lot of DDD writing sounds abstract until you anchor it in something tangible. A professional kitchen works well because every role, rule, and handoff has a clear purpose.

A chef plating a meal to illustrate Domain Driven Design concepts of entities and value objects.

Ubiquitous language

In a strong kitchen, the chef, expediter, and servers use the same terms for the same things. “Fire table seven” means one thing. “Allergy alert” means one thing. They don't invent synonyms mid-service.

That's ubiquitous language in software. Developers and domain experts agree on the terms used in conversations, diagrams, tickets, and code. If the business says “maintenance window,” the code shouldn't call it serviceSlot in one place and jobPeriod in another unless those are different concepts.

Teams that model this visually often benefit from domain model diagrams, because naming problems usually reveal modeling problems.

Entities and value objects

A customer's specific dinner order is an Entity. It has identity. Even if the steak temperature changes from medium to medium rare, it's still the same order.

A recipe card is a Value Object. What matters is its attributes, not a unique identity. If two recipe cards have the same ingredients, timing, and preparation steps, they're effectively the same value.

That distinction matters in code:

  • Entity: Tracked over time, has continuity, usually changes state.
  • Value Object: Immutable where possible, compared by attributes, excellent for concepts like Money, Address, TimeWindow, or GeoCoordinate.

Aggregates and repositories

A meal for a table isn't just a random pile of dishes. Someone controls how it comes together. In DDD, that control point is the Aggregate Root.

Think of the head chef coordinating the final plate. Outside actors don't reach in and mutate any object they want. They go through the root, which enforces the rules. If a meal can't leave the kitchen until all allergy checks are complete, the root protects that invariant.

A Repository is how the application loads and saves aggregates. It's not your business logic. It's the storage boundary. In practice, that means the repository returns domain objects that already know how to enforce rules, not passive records waiting for service code to decide what's valid.

Practical rule: If business rules live mostly in services and your entities only expose getters and setters, you probably don't have a domain model yet.

Strategic Design Bounded Contexts and Context Maps

The biggest DDD mistake isn't overusing aggregates. It's pretending one model can describe the whole business.

In real organizations, the same term means different things in different places. “Vehicle status” means one thing to operations, another to maintenance, and another to billing. Forcing one universal definition creates accidental coupling. That's how the model becomes vague and the codebase becomes political.

A diagram illustrating Strategic Domain-Driven Design concepts including Bounded Contexts, Context Maps, Anti-Corruption Layers, and Shared Kernels.

What a bounded context really does

A Bounded Context is the boundary inside which a particular model is valid. It's a linguistic boundary, a code boundary, and usually a team boundary.

According to Mirko Sertic's DDD example, Bounded Contexts let teams operate autonomously through clear ownership semantics. That supports independent deployment, different technology choices suited to each domain, and less communication overhead across complex projects.

That last point matters more than many architecture diagrams admit. Team autonomy isn't a side effect. It's one of the main reasons to design boundaries well.

Context maps prevent accidental blending

Once you split a system into contexts, you still need to describe how they relate. That's what a Context Map is for.

A useful context map answers questions like these:

Relationship What it means in practice
Anti-Corruption Layer One context translates another context's model instead of importing it directly
Shared Kernel Two contexts intentionally share a small part of a model or codebase
Customer-Supplier One context depends on another's published behavior or interface
Published Language Teams agree on an explicit contract for integration

If you're building toward a service-based platform, domain-driven architecture becomes much easier when these relationships are explicit instead of implied by API calls.

Where teams go wrong

The common failure mode is drawing too many boundaries too early. Another is drawing none at all.

A practical strategic design usually starts with a few questions:

  • Where does the business create advantage? That's the core domain.
  • Which parts change for business reasons, not technical reasons?
  • Where do different teams need independent release control?
  • Which concepts are overloaded today and need separation?

A bounded context isn't a folder structure. It's a promise that a specific model and language apply consistently inside a clear boundary.

When teams honor that promise, complexity becomes localized. When they don't, every integration becomes a leak.

A Practical Domain Driven Design Example Fleet Management

Fleet management is a good domain driven design example because it looks simple from the outside and gets complicated quickly in production. A stakeholder might describe it as “track vehicles, assign drivers, schedule maintenance.” The actual system has compliance rules, route constraints, real-time telemetry, workshop capacity, parts availability, and different definitions of availability depending on who's asking.

That's exactly the kind of domain where a single generic “fleet service” becomes a trap.

A digital fleet management system dashboard showing real-time truck tracking, route status, fuel levels, and driver information.

A team exploring fleet management software development usually discovers that operational workflows and business workflows overlap but shouldn't be modeled as one thing. That's where DDD starts to pay off.

Finding the bounded contexts

Start with business capabilities, not tables or services. In a fleet platform, these boundaries often emerge naturally:

  • Vehicle Telemetry handles GPS position, trip state, sensor feeds, and incoming vehicle events.
  • Route Planning owns route optimization, assignment proposals, stop sequencing, and dispatch constraints.
  • Driver Management deals with driver identity, schedules, certifications, and compliance status.
  • Maintenance Scheduling owns inspections, service jobs, workshop slots, technician assignment, and maintenance state.

You could add billing, fuel management, or customer delivery visibility later. The important part is not making “vehicle” the center of everything. The word appears in several contexts, but it means different things inside each one.

For a broader implementation perspective, teams evaluating bespoke fleet management systems often run into the same issue. Operational domains look unified to the business, but the underlying rules vary enough that one model usually collapses under real requirements.

Zooming into maintenance scheduling

Now take one context and model it properly. Maintenance Scheduling is a strong candidate because it has dense rules and clear business consequences when those rules break.

Inside this context, the central aggregate might be MaintenanceJob.

That aggregate isn't just a record that says “replace brake pads.” It owns a lifecycle and protects rules such as:

  • A job can't be scheduled for a vehicle that is already booked for another maintenance window.
  • A technician assigned to a regulated task must hold the required certification.
  • A job can't move to completed if required checklist items are still open.
  • Parts reservations may need to exist before work starts, depending on job type.

These are domain rules, not API concerns.

Modeling the aggregate

Inside MaintenanceJob, you might have a mix of entities and value objects:

Domain element Likely type Why
MaintenanceJob Aggregate Root Controls all state transitions
TechnicianAssignment Entity or child entity Has identity within the job lifecycle
ServiceWindow Value Object Defined by start and end rules
CertificationRequirement Value Object Compared by attributes
ChecklistItem Entity Tracked individually through completion states
VehicleReference Value Object Represents an external identity without importing another model

The tactical side of DDD takes concrete form. In DDD, Aggregates act as consistency boundaries, and the Aggregate Root enforces business invariants. The Leapcell example shows this with an Order aggregate that validates stock during creation so an invalid order state can't be created, as outlined in this DDD aggregate example.

That same principle applies here. A MaintenanceJob should reject invalid scheduling or assignment decisions at the point of change.

If a rule is required to keep the business state valid, the aggregate should guard it directly.

What the workflow looks like

A typical lifecycle could look like this:

  1. A planner creates a maintenance job for a vehicle based on a service trigger.
  2. The job reserves a service window and records required certifications.
  3. A workshop coordinator assigns a technician through a method on the aggregate root.
  4. The job moves to in-progress only when prerequisites are satisfied.
  5. Completion emits a domain event consumed by other parts of the platform.

Here's a useful reference before moving from model to implementation:

What not to do

The wrong design is common and tempting. Teams create a maintenance_jobs table, an ORM model, and a service layer full of methods like assignTechnician(), startJob(), and completeJob(). Then another service bypasses those methods for a back-office import. Then a batch process updates statuses directly. After that, invariants aren't invariants anymore. They're suggestions.

The better design keeps the business transitions inside the aggregate and treats storage, APIs, and message handlers as delivery mechanisms around the model, not substitutes for it.

Designing a Resilient Aggregate Root

A good aggregate root is strict on purpose. It doesn't expose internal state for random mutation, and it doesn't trust callers to “do the right thing” before saving.

With MaintenanceJob, the aggregate root should be the only entry point for meaningful changes. That protects invariants and keeps the model coherent even when different applications, jobs, and integrations touch the same domain.

Anemic model versus rich model

An anemic model looks tidy at first. It usually has fields, getters, setters, and perhaps a mapper. Business logic sits somewhere else.

That breaks down quickly because every service starts re-implementing rules. One handler checks technician certification. Another forgets. A backfill script sets status directly. The database becomes the last line of defense, which is too late.

A rich model keeps rules where the state changes happen.

from dataclasses import dataclass
from enum import Enum

class JobStatus(Enum):
 SCHEDULED = "scheduled"
 IN_PROGRESS = "in_progress"
 COMPLETED = "completed"

@dataclass(frozen=True)
class ServiceWindow:
 start: str
 end: str

@dataclass(frozen=True)
class TechnicianId:
 value: str

class DomainError(Exception):
 pass

class MaintenanceJob:
 def __init__(self, job_id, vehicle_id, service_window, required_certifications):
 self.job_id = job_id
 self.vehicle_id = vehicle_id
 self.service_window = service_window
 self.required_certifications = set(required_certifications)
 self.technician_id = None
 self.status = JobStatus.SCHEDULED
 self._events = []

 def assign_technician(self, technician_id, technician_certifications):
 if not self.required_certifications.issubset(set(technician_certifications)):
 raise DomainError("Technician lacks required certification")
 self.technician_id = technician_id
 self._events.append({
 "type": "TechnicianAssigned",
 "job_id": self.job_id,
 "technician_id": technician_id.value
 })

 def start(self):
 if self.technician_id is None:
 raise DomainError("Cannot start job without technician assignment")
 if self.status != JobStatus.SCHEDULED:
 raise DomainError("Job can only start from scheduled state")
 self.status = JobStatus.IN_PROGRESS
 self._events.append({
 "type": "MaintenanceJobStarted",
 "job_id": self.job_id
 })

 def complete(self, checklist_complete):
 if self.status != JobStatus.IN_PROGRESS:
 raise DomainError("Only in-progress jobs can be completed")
 if not checklist_complete:
 raise DomainError("Checklist must be complete before completion")
 self.status = JobStatus.COMPLETED
 self._events.append({
 "type": "MaintenanceJobCompleted",
 "job_id": self.job_id,
 "vehicle_id": self.vehicle_id
 })

 def pull_events(self):
 events = list(self._events)
 self._events.clear()
 return events

Design rules that hold up in production

A resilient aggregate usually follows a few hard rules:

  • Protect invariants at the method boundary: Don't allow illegal state and hope validation catches it later.
  • Limit transaction scope: Keep the aggregate small enough that consistency stays local.
  • Emit domain events after valid transitions: Other contexts can react without taking control of this model.
  • Refuse foreign mutations: No direct updates to child objects from outside the root.

For distributed systems, this lines up well with resilience patterns such as the bulkhead pattern. Your aggregate protects domain consistency locally, while the surrounding architecture limits the blast radius of failures across services.

Strong aggregate roots feel inconvenient to developers who want shortcuts. That inconvenience is often what protects production data.

Integrating Bounded Contexts in a Cloud Native World

Once contexts are clean internally, the next challenge is communication. Teams often undo good modeling by letting services query each other's databases or reuse internal DTOs as if they were shared domain objects.

That shortcut creates coupling fast. It also turns every schema change into an integration risk.

A diagram illustrating data flow between three business domains labeled Inventory, Orders, and Payments.

Integration without model leakage

Suppose Maintenance Scheduling needs vehicle details from Vehicle Telemetry. It should not query the telemetry database directly. It should call an API, consume events, or use a translation layer that maps external concepts into its own local model.

That translation layer is often an Anti-Corruption Layer. It protects your context from importing another team's assumptions.

In cloud-native systems, the practical toolset usually looks like this:

  • HTTP or gRPC APIs for synchronous queries where the caller needs an immediate answer
  • Kafka or AWS EventBridge for asynchronous domain events
  • Queue-based workers for retries and background orchestration
  • Per-context storage so each service owns its own schema and persistence decisions

Event-driven collaboration

A strong fit for DDD is event-driven integration. When MaintenanceJob completes, that context can publish MaintenanceJobCompleted. Other contexts react independently.

Examples:

  • Vehicle Telemetry updates service-related operational state
  • Billing records a billable maintenance event
  • Driver Management releases a vehicle back into scheduling workflows

No downstream context needs direct write access to the maintenance model. That's the point.

Avoiding strategic paralysis

Many teams get stuck between “we need domain analysis” and “we need to ship.” Microsoft's guidance on domain analysis highlights this exact problem. Teams often struggle with tactical vs. strategic paralysis, and a practical starting point for legacy systems is to begin with one core domain and one bounded context, then deliver incrementally instead of over-architecting from day one, as described in Microsoft's microservices domain analysis guidance.

That advice holds up in the field.

Use one bounded context to prove a few things:

Question Healthy sign
Can one team own it end to end? They can change, deploy, and support it without broad coordination
Does the model match business language? Product and engineering use the same terms
Are integrations explicit? APIs and events are documented and translated where needed
Is data ownership clear? No one reaches into another context's database

If you can't do that for one context, splitting the rest of the platform won't fix anything. It will just distribute the confusion.

Adopting DDD Pragmatically in Your Organization

DDD isn't a rewrite strategy. It's a decision-making framework for where complexity deserves structure.

The best starting point is usually the business area where changes are both frequent and expensive to get wrong. Pick one core domain. Define one bounded context. Put real business rules inside the model. Keep the first slice narrow enough that one team can own it and learn from it.

Be pragmatic about trade-offs. Not every subsystem needs aggregates and rich modeling. Some areas are simple CRUD and should stay simple. DDD pays off where language is contested, rules are dense, and failures have operational cost.

There's also a newer challenge that most DDD guidance still doesn't address well. AI-assisted development changes how code is produced, but it doesn't change who owns correctness. A notable gap in current DDD literature is how to ensure LLM-generated code respects domain invariants and bounded contexts, which requires stronger prompt design and validation strategies, as discussed in this overview of DDD and its emerging challenges.

That's where disciplined modeling matters even more. If an AI tool generates handlers, tests, or entities, your domain still needs hard boundaries, explicit language, and aggregates that reject invalid state. Otherwise, you'll automate the production of bad architecture.


If you're evaluating how to apply DDD in a cloud platform, modernize a legacy system, or introduce AI-assisted engineering without losing domain integrity, Pratt Solutions helps teams design and deliver practical architectures that hold up under real operational pressure.

John Pratt

John Pratt

Founder, Pratt Solutions · Previously at Northern Trust, Duke Energy, Capital One

Built enterprise systems at Northern Trust, Duke Energy, and Capital One. Now freelancing and building tools that solve hard problems at scale.

More about the author →
© 2026 John Pratt. All rights reserved. | Privacy Policy
Pratt Solutions

Let's talk outcomes.

If you're ready to ship, I'm ready to build.

I'll only use this to respond to your message. No newsletter, no marketing emails, no selling your info.