For my first technical post at Adfin, I thought it would be fitting to start with a topic that’s central to how we build software: Domain-Driven Design (DDD).
There’s a lot to unpack here, but this post is meant to give a quick overview of how we approach DDD in a real-world startup environment - not a textbook. Each section touches on a topic briefly, and if there’s something you’d like me to dig into in more depth, just drop a comment and I would be happy to cover it in a future post.
One of the later sections even introduces a DDD-inspired pattern we've developed ourselves - something we haven’t seen documented elsewhere.
A Quick Refresher: What’s DDD?

At a high level, DDD is about structuring your code and data around the business domain rather than around technical layers. That means modeling the system in terms of aggregates, entities, and value objects, and dividing it into bounded contexts that map to different areas of the business.
The main benefit is a codebase that’s easier to understand, safer and faster to change, and more aligned with how your team thinks about the business.
A modular architecture
We’re a fast-growing startup, and speed matters. But we also don’t want to paint ourselves into a corner.
That’s why we went with a modulith architecture:
- It gives us startup speed - one deploy, no network calls, fewer moving parts.
- At the same time, it sets us up for a smooth migration to microservices when the time comes.
A very important aspect is that each module in our system is fully isolated:
- It lives in its own Gradle project
- It has a dedicated database schema
- It only exposes its contract layer to the outside world and to the other modules - no sneaky cross-module access
- From a module's perspective, every other module is treated like an external service
This keeps our boundaries clean, avoids tight coupling, and most importantly enables us to keep moving fast as the system grows and feature complexity increases.
DDD Module Structure
Every module follows the same layout, and has the following sub-modules inspired by DDD:
- Api: controllers, facades, event consumers - how the module processes outside input and maps it to & from internal input
- Contract: DTOs, interfaces - what the module exposes to the outside world
- Application: Business logic, command and query handlers, aggregates
- We started with a dedicated domain submodule, but later merged it into application - the benefits didn’t justify the extra overhead
- Infra: Repositories, storage clients, AWS clients, any calls to outside systems (incl. other modules)
We enforce strict dependencies:
- Neither contract nor application know about any other submodule
- Api knows only about contract and application
- Infra depends on application and on contracts from other modules
- No other module can access yours except through its contract, and only via their own infra layer
This setup reinforces the modular architecture, keeps things maintainable as the team grows, and makes it fast and safe to build new features.
Aggregates and Events
At the heart of each module are the aggregates - the main building blocks of the domain. When a business action happens (e.g., a payment is made, an invoice is activated), the logic runs inside the relevant aggregate and emits a domain event. Other parts of the system (even across module boundaries) listen to those events and react accordingly.
This has two major benefits:
- Low complexity – We don’t have different flows with custom logic wired together. Instead, every business action behaves consistently as a cause-effect chain.
- Loose coupling – Modules can stay unaware of each other’s internals, responding to events without direct calls.
The result is a system that’s both flexible and easy to reason about - even as the number of features grows. It also encourages clean design: business actions stand on their own and get reused across different flows.
When Relationships Get Complex: Linking Aggregates
Sometimes two concepts are so intertwined that keeping them fully separate causes more harm than good. That was the case for us with Payment Requests (PRs) and Invoices.
They’re tightly coupled:
- Operations on a PR often need invoice data for validation
- Operations on an invoice often need PR data
- Status updates affect both sides and must stay in sync
- There’s added complexity due to the different possible associations: one-to-one, one-to-many, many-to-one, or even single-use links
Instead of constantly querying across modules or duplicating logic, we chose a DDD-inspired approach: introduce a dedicated aggregate to model the relationship itself.
We call it the PrInvoice aggregate.
This aggregate doesn’t exist in the database - it's a business-layer-only construct that brings together all the data needed to perform operations cleanly and consistently.
Having this aggregate means:
- No more scattered logic - all validations and updates happen in one place
- We always have the full context when performing an operation
- The relationship logic is explicit and enforced
This pattern isn’t something you’ll find in the standard DDD books - but it’s been one of the most impactful design choices for our team.
Persisting complex aggregates
Many of our aggregates are rich object graphs with nested entities, lists, and value objects. Thanks to JPA, we can persist and retrieve in one go entire aggregates, including all the nested entities. It keeps the code clean and the aggregate whole - without a bunch of boilerplate.
Projections: optimizing queries while keeping boundaries
To respect aggregate boundaries, we only query one aggregate at a time. If a query spans multiple domains, we solve that with projections.
Example:
We show the customer’s name on invoices and payouts. Rather than join across aggregates, we project the name: when a customer is created or updated, we emit an event (like CustomerNameChanged) that’s handled by the invoice and payout modules.
Projections are a lightweight way to maintain strict module boundaries without sacrificing performance.
Final Thoughts
DDD gives us a structure that’s helped us grow fast without losing control over complexity. It’s not always easy to get right, but the payoff is real when it’s aligned with your architecture, tooling, and team.
We’ll continue to evolve our approach as the system grows, and I’ll be sharing more of that journey in future posts. If there’s a section you'd like to hear more about, drop a comment. We’ll follow up with more focused posts in the future!
