
We adopt the Domain-Driven Design (DDD) software design approach that places the product domain — its logic, processes, and language — at the center of software architecture and implementation. The goal is to create systems that reflect the real-world problems they are intended to solve, using a shared vocabulary and model that aligns technical structures with the needs of the product.
Ubiquitous Language
A core principle of DDD is alignment on a Ubiquitous Language — a common vocabulary shared by domain experts and developers. This language is:
- Grounded in the domain, not technical jargon
- Reflected in class names, APIs, and documentation
- Evolved collaboratively as the domain understanding grows
Using this language:
- Improves communication and reduces ambiguity
- Encourages alignment between product intent and implementation
- Makes the system more readable and maintainable over time
Language as a Clue for Bounded Contexts
As teams explore the domain in greater depth, differences in terminology often reveal natural boundaries. For instance:
If the term “customer” means something different in Sales vs. Support, these likely belong in different bounded contexts.
If one team talks about “invoices” and another about “bills,” that may indicate two different models of the same concept.
Observing how stakeholders describe their work helps uncover where one model ends and another begins — this is the essence of identifying bounded contexts.
Domains, Services and Teams
DDD techniques map the architecture of the system to the product domain. This alignment supports:
Each service or logical boundary in the system to represent a bounded context with its own domain model.
Independence, scalability, and autonomy of teams — each can evolve their service and model without tightly coupling to others.
Services benefit from the explicit boundaries and product-aligned logic DDD encourages, making the system easier to reason about, maintain, and evolve.
These services communicate via domain events and remain loosely coupled while maintaining strong internal cohesion.
By designing services around product capabilities (bounded contexts), DDD helps avoid the “technical silo” anti-pattern where services are structured around technical layers rather than user needs.
Modular Monoliths: Easing the Path to Microservices
Image credit: The App Continuum
Starting with a modular monolith guided by DDD principles is a pragmatic approach that balances simplicity with long-term flexibility. By organizing code around Bounded Contexts and maintaining clear domain boundaries, teams can build a monolith that is modular, testable, and easier to evolve.
This architectural discipline means that if the need arises — due to factors like scaling requirements, rate of change, or organizational boundaries—individual modules can be extracted into microservices with less friction. Since the domains are already isolated, the technical and logical seams for service boundaries are well-defined, reducing the risk of creating tangled dependencies or duplicated logic during decomposition.
Should that be a Microservice?
Here are some factors to consider when deciding whether to design, or extract and deploy a module as a separate service:
Multiple Rates of Change - does the module change for different reasons to the rest of the system?
Independent Life Cycles - does the module have a different development, testing and deployment cycle, for product reasons?
Independent Scalability - does it have different scaling characteristics or expected load?
Failure Isolation - do we want to be able to protect the rest of the system from a type of failure that’s more likely to happen in this module?
Simplify External Dependencies - does the module act as a facade to an external dependency that can change?
The Freedom to Choose the Right Tech for the Job - if a particular technology is a better choice for this type of service, it can be implemented in a different tech stack from the rest of the system, communicating back via standard protocols.
Organisational Alignment - if the module is owned by a different team, extracting it as a separate service can help the teams not step on each other’s toes.
In this way, DDD and modular monoliths allow teams to delay the complexity of microservices until it’s truly needed, while still preparing the codebase for a clean and deliberate transition when the time is right.
Project structure
To enable this modularity, we can use refactoring techniques to evolve our codebase as it grows through the following stages of DDD maturity:
Flat - everything in one directory. The simplest starting point, if we know nothing about the domain and have made no decisions.
Functional groups - “horizontal” grouping by function, eg. Controllers, Models, Services… changes necessarily cut across many areas of the codebase.
Feature groups - “vertically sliced” modules representing bounded contexts, with all the functions for that feature together, eg.
AccountController,AccountService,Account,AccountRepositoryin theAccountsmodule. It’s easy to find the module where a change has to be made, and changes can usually be isolated to one module, since they are highly cohesive and loosely coupled.
If needed, the codebase can be evolved further to support deployment as separate services.
Components - each module as its own separate buildable and testable unit. The implementation will be tech-specific, eg. Angular modules, Gradle or Maven projects. Dependencies between modules are specified explicitly in each one’s build file.
Applications - it’s relatively trivial at this point to extract the module out as its own deployable service, with a runnable wrapper. However, the ramifications and overhead of doing so are not trivial, and should be considered according to the points above. Module dependencies, instead of being in-memory function calls, are eg. REST API calls, and accordingly slower and less reliable.
Here’s an example codebase with git tags showing these different stages of abstraction, and how modularity makes this evolution possible.
Event Storming: Lightweight Domain Modelling
Event Storming is a collaborative, lightweight technique for discovering and visualising domain knowledge. It is particularly useful at the beginning of a project or when exploring a new domain.
How it works:
- Gather cross-functional participants: Developers, product owners, subject matter experts, or relevant agencies.
- Start with domain events: Write down key business events as sticky notes (e.g., Order Placed, Invoice Sent).
- Add commands, aggregates, actors, and external systems: Build a timeline and flow that reveals business processes and dependencies.
- Identify bounded contexts: Group related events and logic, surfacing potential service boundaries.
Benefits of Event Storming:
- Uncovers blind spots and assumptions early
- Builds a shared understanding and ubiquitous language
- Encourages business-technical collaboration
- Helps map business workflows into software boundaries
Summary
Domain-Driven Design principles and techniques are invaluable when building software systems that mirror the complexity of real-world government operations. Techniques like Event Storming and Ubiquitous Language help teams collaboratively explore and model these domains, ensuring the software is in service of the product, and not the other way around. Aligning the logical modules of the system with the bounded contexts of the product domain reduces the risk of architectural decisions by making it easier to evolve.