On my latest project I worked on a Kotlin and Spring Boot solution within transportation management, where we decided to use Domain-Driven Design (DDD) and Clean Architecture. This post is a retrospective of the benefits and challenges of doing that.
A bit of context: The system is a modular monolith shared by three teams. The team size varied throughout the project, but at most we were about 16 people in the same "group", spread over those three teams. There's more to the project than that, but those details aren't important for this post.
Note
The project was written in Kotlin, but most of the code examples in this post are in Java, simply because Java is more commonly known.
Parts specific to Kotlin will be shown in Kotlin.
When we started doing DDD it became clear that a lot of developers have very little knowledge of it, and many who claim to know it disagree with each other (and with me). Getting aligned on DDD was not easy, and arguably not a success (which is something we will talk about later).
For this post to make sense we (you and me, reader!) need to be clear on my flavour of DDD. This might also highlight some of the points later on in this post.
To me, there are two distinct parts to DDD. The first one is the language, aka what Evans calls the ubiquitous language. It is a shared language that developers and domain experts build together, and which is reflected directly in the code.
In this project, the domain (and the transport industry as a whole) already had a lot of established terms, so a big part of the work was for us developers to adopt those terms rather than invent our own.
In short, we should avoid situations where the code, the developers and the business use different terms for the same thing.
In practical terms, this means that we should be able to show the code to a non-technical person and they should roughly understand what it does. For example:
public class Account {
private Amount balance;
public void withdraw(Amount amountToWithdraw) {
if (balance.minus(amountToWithdraw).isNegative()) {
throw new InsufficientFundsException();
}
balance = balance.minus(amountToWithdraw);
}
}
The above is a very simplified example, but a non-technical person can understand what Account means given the context. They understand what it represents. They also understand balance and amountToWithdraw. They can understand the check that stops the balance from going negative, and so forth.
They might not understand what an exception is in Java, which is fine, but they can understand that we handle the case where there are insufficient funds in some way.
All in all, just by how we name parameters, classes and methods, we can put our code in front of non-technical people and they can understand what is going on. Even more importantly, they can spot if cases are missing or if things happen in the wrong order. This is a huge boon.
A lot of people seem to understand the ubiquitous language part, but they struggle with the technical implications of it.
DDD doesn't prescribe a specific implementation. It is somewhat fluid, and as long as the structure supports the ubiquitous language and the domain, it doesn't really care what that structure looks like.
My version of DDD builds on the building blocks from Eric Evans' blue book "Domain-Driven Design: Tackling Complexity in the Heart of Software", with one notable difference that I'll get to shortly. I tend to lean towards onion architectures such as Clean Architecture or Hexagonal Architecture.
Note
I'm not going to get into Clean Architecture or Hexagonal Architecture in this post. I think both are viable, and the main thing I care about is that the domain is independent from everything else (which both offer).
I tend to lean towards Clean Architecture simply because it is a bit more open-ended, but my experience is that it doesn't really matter which one is used.
For now, let's ignore everything outside the domain "layer". What matters to me is that the domain (which holds the business rules) is completely independent of any other dependencies. No database calls, no HTTP stuff, no message queue things. Not even through injected interfaces. The domain only consists of plain, regular code, and its whole purpose is to enforce business rules and ensure correct state.
This is where I deviate from Evans. In the blue book, repositories are part of the domain, and domain services are allowed to use them. I keep repositories (and anything else that talks to the outside world) out of the domain, because that means every business rule can be tested without mocks or fakes. We'll get back to why that matters in the benefits section.
The domain is the core of the application, and it is what this post is about. Persisting it, exposing it through an API and so on happens elsewhere, and I don't have strong opinions on how the domain gets translated to a database.
I won't go into detail about everything, but the domain is built from these building blocks:
A value object is an object that is defined by its values rather than by an identity. Two value objects with the same values are equal and can be used interchangeably. A 100 kroner note is just as good as any other 100 kroner note - we don't care which one we have, only what it is worth.
A typical example is storing names:
public record FullName(String firstName, String lastName) {
public FullName {
if (firstName == null || firstName.isBlank()) {
throw new IllegalArgumentException("First name cannot be blank");
}
if (lastName == null || lastName.isBlank()) {
throw new IllegalArgumentException("Last name cannot be blank");
}
}
public String formalName() {
return lastName + ", " + firstName;
}
}
Above, we ensure that both firstName and lastName follow the rules for those properties before we store them in the object. That means that if we have a FullName, we know it is valid. We never have to check it again. This is a way of ensuring correctness through the type system, which I've written about before.
Value objects are almost always immutable. So changing someone's name means replacing the entire instance rather than modifying the existing one.
Date of birth is also pretty common, and this can again be represented as a value object:
public record DateOfBirth(LocalDate value) {
public DateOfBirth {
Objects.requireNonNull(value, "Date of birth cannot be null");
}
public Age ageOn(LocalDate today) {
return new Age(Period.between(value, today).getYears());
}
}
ageOn gives us an Age, which is a value object in its own right:
public record Age(int years) {
public Age {
if (years < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
public boolean isAdult() {
return years >= 18;
}
}
Here we also see that value objects can contain behaviour, not just values. What it means to be an adult lives in Age, and nowhere else.
Notice that DateOfBirth never asks the system clock what day it is. Today's date is passed in instead. The clock is a dependency too, and by keeping it out of the domain, the same input always gives the same result - which makes tests predictable.
IDs can also be value objects:
public record TeamMemberId(UUID value) {
public TeamMemberId {
Objects.requireNonNull(value, "Team member ID cannot be null");
}
}
Wrapping IDs like this might seem excessive, but it means the compiler stops us from passing a TeamId where a TeamMemberId is expected.
And when a value can only be one of a fixed set of options, like the role someone has on a team, an enum does the job:
public enum Role {
DEVELOPER,
LEAD
}
A team also belongs to a department. Two departments with the same name are the same department, so that is a value object too:
public record Department(String name) {
public Department {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Department name cannot be blank");
}
}
}
An entity is an object with an identity that stays the same throughout its lifetime, even when everything else about it changes. A team member who changes their name and role is still the same team member. Because of that, two entities are equal if they have the same ID, regardless of their other values. That's why equals and hashCode below only look at id.
public class TeamMember {
// Child entity identity (unique, but scoped within the aggregate boundary)
private final TeamMemberId id;
// Value objects
private FullName fullName;
private DateOfBirth dateOfBirth;
private Role role;
public TeamMember(TeamMemberId id, FullName fullName, DateOfBirth dateOfBirth, Role role) {
this.id = Objects.requireNonNull(id);
this.fullName = Objects.requireNonNull(fullName);
this.dateOfBirth = Objects.requireNonNull(dateOfBirth);
this.role = Objects.requireNonNull(role);
}
// Package-private behaviour: Only the aggregate root can modify this child entity
void changeRole(Role newRole) {
this.role = Objects.requireNonNull(newRole);
}
void changeName(FullName newName) {
this.fullName = Objects.requireNonNull(newName);
}
public boolean isLead() {
return role == Role.LEAD;
}
// Read-only accessors
public TeamMemberId id() { return id; }
public FullName fullName() { return fullName; }
public DateOfBirth dateOfBirth() { return dateOfBirth; }
public Role role() { return role; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TeamMember that = (TeamMember) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
Notice that the methods that change the team member are package-private. Nobody outside the domain package can change a team member directly. That brings us to aggregate roots.
An aggregate is a cluster of entities and value objects that we treat as a single unit, and the aggregate root is the entity at the top of that cluster. Everything outside the aggregate goes through the root. We don't fetch a TeamMember and change its role - we ask the Team to do it.
This matters because some rules span the entire cluster. A team can't have more than five members, and nobody can be assigned to the same team twice. No single TeamMember can enforce that, but the Team can, since it sees all of them. As long as every change goes through the root, the aggregate is always in a valid state.
Aggregates are also loaded and saved as a whole. We never load half a team. The aggregate is our consistency boundary.
public class Team {
private final TeamId id;
private final Department department;
private String name;
private final List<TeamMember> members = new ArrayList<>();
private static final int MAX_TEAM_SIZE = 5;
public Team(TeamId id, Department department, String name) {
this.id = Objects.requireNonNull(id);
this.department = Objects.requireNonNull(department);
this.name = Objects.requireNonNull(name);
}
public void assignMember(TeamMemberId memberId, FullName fullName, DateOfBirth dateOfBirth, Role role,
LocalDate assignedOn) {
if (isFull()) {
throw new TeamIsFullException(id, MAX_TEAM_SIZE);
}
if (!dateOfBirth.ageOn(assignedOn).isAdult()) {
throw new MemberIsUnderageException(memberId);
}
if (isOnTeam(memberId)) {
throw new MemberAlreadyOnTeamException(id, memberId);
}
members.add(new TeamMember(memberId, fullName, dateOfBirth, role));
}
public void unassignMember(TeamMemberId memberId) {
members.remove(member(memberId));
}
public void changeMemberRole(TeamMemberId memberId, Role newRole) {
member(memberId).changeRole(newRole);
}
public boolean isFull() {
return members.size() >= MAX_TEAM_SIZE;
}
public boolean isOnTeam(TeamMemberId memberId) {
return members.stream().anyMatch(m -> m.id().equals(memberId));
}
public TeamMember member(TeamMemberId memberId) {
return members.stream()
.filter(m -> m.id().equals(memberId))
.findFirst()
.orElseThrow(() -> new MemberNotOnTeamException(id, memberId));
}
public TeamId id() { return id; }
public Department department() { return department; }
public String name() { return name; }
public List<TeamMember> members() {
return Collections.unmodifiableList(members);
}
}
Notice that the exceptions are named after the rule that was broken, like TeamIsFullException, rather than being a generic IllegalStateException. Just like InsufficientFundsException in the first example, a non-technical person can read them and understand what went wrong.
A domain event is a record of something that happened in the domain that other parts of the system might care about. Events are named in the past tense, using the business language: TeamMemberAssigned, TeamMemberUnassigned and so on.
Since the domain can't call anything outside itself, events are how it says "this happened" without knowing who's listening. The aggregate records the events as its state changes, and something outside the domain picks them up and publishes them once the change has been saved:
public interface DomainEvent {}
public record TeamMemberAssigned(TeamId teamId, TeamMemberId memberId) implements DomainEvent {}
public class Team {
// ... same fields as before ...
private final List<DomainEvent> events = new ArrayList<>();
public void assignMember(TeamMemberId memberId, FullName fullName, DateOfBirth dateOfBirth, Role role,
LocalDate assignedOn) {
// ... same checks as before ...
members.add(new TeamMember(memberId, fullName, dateOfBirth, role));
events.add(new TeamMemberAssigned(id, memberId));
}
public List<DomainEvent> pullEvents() {
List<DomainEvent> pulled = List.copyOf(events);
events.clear();
return pulled;
}
}
unassignMember records a TeamMemberUnassigned event the same way.
The Team doesn't know or care whether anyone sends a welcome email or sets up access when a member is assigned. It just records that it happened. We'll see who publishes the events in the next section.
Sometimes a business rule doesn't naturally belong to a single aggregate. Let's say the business wants to transfer a team member from one team to another, with the following rule: Members can only be transferred between teams in the same department.
Where should that logic live? Not in the TeamMember, since it has no idea what teams exist. Not in either Team either. The rule is about the relationship between the two teams, so neither of them can decide it on its own. The transfer itself also spans both teams: the member is assigned to one and unassigned from the other. An operation that involves several aggregates, but belongs to none of them, is what domain services are for. Evans' own example is transferring money between two bank accounts:
public class TeamTransfers {
public void transferMember(TeamMemberId memberId, Team from, Team to, LocalDate transferDate) {
if (!from.department().equals(to.department())) {
throw new TransferOutsideDepartmentException(from.id(), to.id());
}
TeamMember member = from.member(memberId);
// Assign first, so the new team's rules (full team, already on the team, etc.)
// are checked before the member is unassigned from their current team
to.assignMember(member.id(), member.fullName(), member.dateOfBirth(), member.role(), transferDate);
from.unassignMember(memberId);
}
}
A domain service is still a part of the domain, so the same rules apply: No databases, no HTTP, no injected interfaces. It receives the aggregates it needs, applies the business rules and changes their state. It is just plain code that is named after something the business would recognise - "transferring a team member".
To be honest, though: We never used a single domain service in this project. Every time it seemed like we needed one, it turned out that the rule fit within a single aggregate after all, or that the aggregates were split up in the wrong way. Domain services have their place, but use them sparingly. They are often not needed, even when it might seem like it, and the more logic that ends up in services, the closer we get to the anemic domain model we were trying to avoid.
This is where many get confused, because there's also something called an application service. The application service lives outside the domain and is the one that makes the use case actually happen:
@Service
public class TransferTeamMemberUseCase {
private final TeamRepository teamRepository;
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
private final TeamTransfers teamTransfers = new TeamTransfers();
public TransferTeamMemberUseCase(TeamRepository teamRepository, ApplicationEventPublisher eventPublisher,
Clock clock) {
this.teamRepository = teamRepository;
this.eventPublisher = eventPublisher;
this.clock = clock;
}
@Transactional
public void transfer(TeamId fromTeamId, TeamId toTeamId, TeamMemberId memberId) {
Team from = teamRepository.findById(fromTeamId)
.orElseThrow(() -> new TeamNotFoundException(fromTeamId));
Team to = teamRepository.findById(toTeamId)
.orElseThrow(() -> new TeamNotFoundException(toTeamId));
teamTransfers.transferMember(memberId, from, to, LocalDate.now(clock));
teamRepository.save(from);
teamRepository.save(to);
from.pullEvents().forEach(eventPublisher::publishEvent);
to.pullEvents().forEach(eventPublisher::publishEvent);
}
}
The difference is in what they care about:
A good litmus test is to read the application service out loud to a business person. If they start nodding along and say "Yes, and remember that ...", then business logic has leaked out of the domain. If their eyes glaze over, then it is probably right.
The language part of DDD just worked. Since the code used the same terms as the business, communicating with non-technical stakeholders was easy. Nobody had to translate between what the business called something and what the code called it.
It also paid off when users reported bugs or came with requests. The words they used were usually the names of our classes and methods, which made it easy to map a request to the relevant part of the code.
This is the payoff of keeping repositories out of the domain. Since the domain had no external dependencies, I could write tests without any mocks or fakes. No mocked repositories, no stubbed HTTP clients and no in-memory databases. Just create some objects, call a method and check the result.
Not having to deal with mocks or fakes is incredibly freeing. Being able to just write working code and not worry about frameworks, dependencies, libraries and such is amazing.
This meant all the business-relevant tests were super fast. That speed also allowed for things like fuzzing and property-based testing, where we threw a large amount of generated input at the domain to see if we could break any rules - which would have been painfully slow if every test needed to spin up a Spring context or a database.
One example of this is date/time conversion, which we had to do. If you've ever done any major work involving date and time, you know how many edge cases you can run into. Being able to truly fuzz test this effectively was actually pretty vital.
The result was that what mattered most to the business was also what was easiest to test. In my experience, that is rarely the case when the logic is spread across multiple services, hidden behind mocked behaviours or just so deeply nested with dependencies that you end up doing more mocking than actual testing.
I cover this way of testing in more detail in my JavaZone 2024 talk.
Here I do want to make one concession though: Earlier in my post I pointed out that I didn't let the domain have injected dependencies that resulted in I/O calls. I think that is the correct option for most scenarios, but I do also want to point out that I'm not necessarily dogmatic about it. Tying back to my core belief as an architect: Make rules that work for 95% of situations, make exceptions for the rest.
Architectural decisions that must cover 100% of situations quickly become a "One size fits all, fits no one at all" kind of situation. So, I tend to lean towards making decisions that fit the vast majority of cases, let the painful use cases reveal themselves and make exceptions based on that.
Since all the business rules were baked into plain Kotlin classes, it was easy to add new requirements and features.
For example, if the business decided that archived teams shouldn't accept new members, we'd add an archive() method to Team and a check at the top of assignMember. One class to change, and a couple of tests to write. There's no need to figure out which of the services, controllers or listeners might also assign members to a team, because they all have to go through Team.
Compare that to a typical 3-layer architecture with an anemic domain model (which I've written about before), where the rules are spread across services that call each other. Adding a new rule often means juggling services, hunting down every place that touches the data and hoping you found them all.
For the same reasons, finding issues was easy. When a bug report came in, we could usually recreate the exact scenario in a test within minutes, without having to set up any infrastructure.
What made this really work were test helpers that simplified creating domain objects. We used Kotlin functions with default parameters, so each test only had to specify what was actually relevant to it:
val today: LocalDate = LocalDate.of(2026, 1, 1)
fun aTeamMember(
id: TeamMemberId = TeamMemberId(UUID.randomUUID()),
fullName: FullName = FullName("Ada", "Lovelace"),
dateOfBirth: DateOfBirth = DateOfBirth(LocalDate.of(1990, 1, 1)),
role: Role = Role.DEVELOPER,
) = TeamMember(id, fullName, dateOfBirth, role)
fun aTeam(
department: Department = Department("Operations"),
name: String = "Platform",
members: List<TeamMember> = emptyList(),
): Team {
val team = Team(TeamId(UUID.randomUUID()), department, name)
members.forEach { team.assignMember(it.id(), it.fullName(), it.dateOfBirth(), it.role(), today) }
return team
}
Which makes the tests read almost like the bug report itself:
@Test
fun `a member cannot be transferred to a team in another department`() {
val member = aTeamMember()
val from = aTeam(department = Department("Operations"), members = listOf(member))
val to = aTeam(department = Department("Finance"))
assertThrows<TransferOutsideDepartmentException> {
TeamTransfers().transferMember(member.id(), from, to, today)
}
}
For a TDD guy like me, being able to run tests quickly and being able to reproduce issues as tests is essential.
Aggregates are great at protecting business rules when data changes, but they are terrible at answering questions. "Show me all teams, with their member count" means loading every Team aggregate with all its members, only to throw most of it away and map the rest into whatever the API wants. It is slow, and it puts pressure on the domain to grow getters and structures that only exist to serve a specific screen.
The solution is a separate read path that bypasses the domain (CQRS). It removes so much complexity. The domain is only used for things that change data (commands), while reads (queries) go straight to the database and fetch exactly what is needed:
@Service
public class TeamQueries {
private final JdbcClient jdbc;
public TeamQueries(JdbcClient jdbc) {
this.jdbc = jdbc;
}
public List<TeamOverview> teamOverviews() {
return jdbc.sql("""
SELECT t.id, t.name, COUNT(m.id) AS member_count
FROM team t
LEFT JOIN team_member m ON m.team_id = t.id
GROUP BY t.id, t.name
""")
.query(TeamOverview.class)
.list();
}
}
There are no business rules to protect when reading, so there's no reason for reads to go through the domain.
CQRS doesn't have to mean separate databases, event sourcing or any of the other heavy machinery it is often associated with. A separate read path in the same codebase, against the same database, gets you most of the benefits. Keeping the domain limited to what changes data makes everything much easier.
Nothing comes for free, unfortunately.
I have a saying "Software development would've been easy if it wasn't for all the people", and sure, that is cheeky... but it is also kind of true. Unclear specifications, disagreements, office politics, deadlines (set by people), pressure from management, user demands, etc. All stuff rooted in people.
On the other hand, software without people would be meaningless. Easy, sure, but meaningless. And the more people you have, the more "stuff" comes with it.
It turned out that the challenges with DDD were mostly about people, rather than DDD itself.
As mentioned at the start, getting aligned on DDD was a struggle.
The biggest hurdle was the idea of a class having both data and behaviour. Many developers (like the LLMs, funnily enough) instinctively wanted to push the logic out into services and keep the domain classes as plain data holders - the anemic domain model mentioned earlier. That is how most of us learned to build applications, so it is understandable, but it undermines the whole point. If the rules live in services, the domain can no longer guarantee that it is in a valid state.
Beyond that, there weren't really any camps. The problem was that the resources online made it difficult to arrive at the same understanding. Every blog post, book and talk has its own take on DDD, and since DDD is fluid (as I mentioned earlier), they're rarely wrong - just different. Everyone ended up with a slightly different understanding, and we started arguing about details: Where should this piece of logic live? Is this a domain service or an application service? Should this be an event?
None of these were big disagreements on their own, but together they made for a lot of minor bickering and made pull requests harder than they needed to be - especially since alignment between the three teams was a goal.
This project started when AI really took off amongst developers, and it turns out that even the most up-to-date models struggle with DDD. They have a real hard time deviating from the 3-layer architecture, and they always try to revert back to it.
It doesn't matter how well you prompt, which skills you set up or what "rules" you define for your project: AI simply refuses to implement DDD properly and makes really weird decisions.
DDD truly showcases the lack of actual understanding going on.
This was quickly observed with the developers who wanted to learn DDD, but also wanted to use AI. They got some code generated, which technically worked but missed the point, and then submitted it because they didn't know any better.
I have also tried to use LLMs to set up a DDD structure, but even the most basic example falls apart.
My theory is that there are so few open-source DDD codebases that LLMs simply cannot produce any meaningful results.
Some developers have claimed that this reflects poorly on DDD. That the fact that LLMs cannot do decent DDD shows that there's something fundamentally wrong with DDD. I disagree.
I am also wary of a scenario where we pick tools based on what works well with LLMs. Where ideas are disregarded because they aren't "AI-approved". This is, to me, fundamentally dangerous.
My experience of developers who cannot learn without AI is also extremely troubling. Studies suggest that LLMs impact critical thinking (Microsoft Research, MIT Media Lab). Even Anthropic found that developers who used AI assistance while learning a new library understood it significantly worse than those who coded by hand. I fear that the ability to learn is on the chopping block.
DDD and onion architectures draw a pretty hard line between the domain and the rest of the application, but keeping that line isn't always easy. We need extra mapping logic to massage data to and from domain models and other representations, which is "extra stuff" that developers need to consider.
We also have domain events, which need plumbing outside the domain to be collected, published and handled.
This means that there's a bunch of extra stuff around the domain that wouldn't normally be there, which has to be maintained and balanced.
On top of that, it can be difficult to know what should be a domain event and what should be a nested entity within an aggregate. Let's say that every new team member needs an onboarding checklist. Should the checklist be an entity inside the Team aggregate? Then the Team can guarantee that nobody is assigned without one, but the aggregate grows, and every change to a checklist now has to go through the team. Or should the checklist be its own aggregate that gets created when a TeamMemberAssigned event is published? Then both stay small and focused, but for a short while there's a team member without a checklist, and we need the plumbing to publish and handle the event.
The best rule of thumb I know of comes from Vaughn Vernon's "Effective Aggregate Design": If the rule must hold at all times, keep it within the same aggregate. If it is fine for it to hold "shortly after", then use an event. But even that rule of thumb leaves a lot of room for interpretation. In practice, it is more a matter of "feeling what is right" than following a hard rule, which isn't very helpful for developers who are new to DDD (and fuels the "my DDD vs your DDD" discussions).
As previously mentioned: Around the domain there's extra stuff that wouldn't be there in a simple 3-layer architecture. Yet there it is. When greenfielding a project, that might feel like over-engineering... and often it is... at that stage.
When it comes to architecture, we must balance today's demands and future demands. KISS and YAGNI might imply that we should never do anything before it is actually needed, but that isn't universally true.
When we work on projects, especially in larger established organisations, we have a good idea of the scope. We have a good idea of how many users we can expect and how big the codebase will likely become. We know whether the problem and the scale are non-trivial. Therefore, we should take that into account when we start building the project.
What many architects do is build the simplest thing first, with the most straightforward architecture. That is completely fine, but it makes a shift later much more challenging, as it means restructuring the core of the application. So what often happens is that the application continues with its simple architecture, regardless of whether it actually is fit for purpose.
I disagree with that approach. I am much more in favour of ballparking what we need. At least then we get pretty close architecturally, and likely end up with something that fits pretty well.
Every architecture has pain points, usually tied to scale: Scaling the application or scaling the codebase (i.e. the amount of code). Having a 3-layered CRUD application, for example, when a bunch of very complex rules need to be checked, is a struggle. But having an onion architecture in a simple CRUD system with no checks is also painful.
Picking an architecture that is close to the known complexity - preferably something that can be massaged into something that fits very well without too much restructuring - is the best option, even though it leads to a more painful start.
The problem with this approach is that sceptics within the team use that pain as an excuse to disregard said architecture. They point to early growing pains as proof that it doesn't work. The worst part is that they are right... but only for a while. As the system grows the benefits reveal themselves, but people have a real hard time accepting that. In my experience, most people want the simplest thing today, not what will become the simplest thing tomorrow.
This was a struggle to begin with, which led to parts of the codebase breaking away from the chosen architecture - and those parts had many more bugs and struggles later on in the project (partly due to those decisions).
First off: I think DDD is absolutely worth it for most projects of a certain size and complexity. Before this project, my experience with DDD was limited due to buy-in challenges. I had built DDD projects before, but for unrelated reasons they never reached production, or they were private projects. Based on those past experiences I was convinced that DDD would be great, and it turned out to be great.
Being great doesn't mean being perfect, though. I would not do DDD if parts of the team don't want it, or if the team isn't aligned on what DDD is. The members who don't want it must be able to accept the decision and commit to it, and a shared definition of DDD is an absolute must. Without that, it'll just become a mess.
The next time I have the opportunity to use DDD, I will start by running a workshop where each developer individually implements the same small project (it should take about 30 minutes). No tests, no real database and no real input. It is purely about structure. Then we can compare the results and align, and the version the team agrees on becomes our reference implementation: our flavour of DDD, written down in code that people can copy from. "Follow DDD" is not a guideline when everyone has their own idea of what DDD is.
The "early complexity" problem must also be communicated within the team. If not, someone will inevitably bring out the "premature optimisation" or "over-engineering" argument.
I don't want to turn this into a debate about AI, but letting AI define the domain is a bad idea. Partly because today's AI cannot do DDD properly, but also because there's value in developers understanding the domain.
Something I experienced when working with pure DDD and onion architecture is that I gained a really deep knowledge of the domain, and it was easy to mentally separate what the business wanted from what the application cared about.
I therefore think there's value in having developers "hand code" the domain. It gives them an understanding of the concepts and what they mean, and it ensures that the domain represents... well... the business domain. Overall, that leads to developers who are much better suited to work in that domain.
Sure, AI can contribute later on. AI does pretty well when it comes to mapping the domain to a database model or to API responses. There are some pitfalls to be aware of, though. For example, LLMs really want to cheat by turning domain classes into data classes and using the copy method to change their state. That bypasses the rules in the domain's methods. If a member could only be unassigned under certain conditions, copy would just hand the team a new list of members without that member, and the rule would never run. But there is room for AI for those who really want it.
There are a lot of soft rules in DDD, where there's room for opinions and whatnot. Some allow domain services to have repositories injected so they can fetch data, for example. Others are fine with JPA annotations on domain classes. As we saw in the "My DDD vs your DDD" section, every soft rule is an opportunity for a discussion in a pull request.
The best way to end those discussions is to turn the soft rules into hard rules that blow up the build. In the project, I used ArchUnit to enforce rules tied to the modular monolith part of the system. Given the opportunity, I would also write rules that ensure the domain cannot depend on whatever it wants, and that the domain cannot be called from wherever:
@AnalyzeClasses(packages = "com.example.teams")
class ArchitectureTest {
// The domain is plain code: no Spring, no JPA, no application or infrastructure code
@ArchTest
static final ArchRule domainHasNoDependencies = noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage(
"..application..",
"..infrastructure..",
"org.springframework..",
"jakarta.persistence..");
// Controllers go through application services, never straight to the domain
@ArchTest
static final ArchRule webDoesNotTouchDomain = noClasses()
.that().resideInAPackage("..web..")
.should().dependOnClassesThat()
.resideInAPackage("..domain..");
}
Rules like these are cheap to write, and they settle a discussion once instead of in every pull request. They also work just as well on AI-generated code as on code written by hand.
Overall, I'm very happy with DDD. 10/10. Will use again.