Implementing DDD in Java: Notes from a Workshop
Introduction
I’ve recently taken part in a multi-part workshop that dove into the Tactical Patterns of Domain-Driven Design (DDD) in Java. Rather than just tossing around theory, the sessions were rooted in a hands-on case: price calculation logic in a recycling center—a deceptively rich domain full of rules, exceptions, and cross-context interactions.
This blog series is my attempt to revisit what I’ve learned, organize my thoughts, and deepen my understanding. It’s not just a recap—it’s me learning out loud, and maybe giving you a shortcut if you’re also trying to wrap your head around DDD.
In this first post, I’ll share the foundational ideas that the workshop was built on and the path it took us down, as well as try to explain some of the core ideas of DDD. In follow-up posts, I’ll go into more detail on the patterns we used and how they shaped the implementation.
The Complexity Challenge
When we build software without deeply understanding our domain, something interesting happens: the knowledge required to make changes grows exponentially over time. Each new feature becomes harder to implement than the last, and what started as a “simple” system becomes a tangled mess.
This is where DDD comes in. While it requires more upfront investment in understanding and modeling, it helps manage complexity in a way that scales with your problem space. Yes, as your domain gets more complex, your implementation does too—but not exponentially.
There are two types of complexity we need to distinguish:
- Essential Complexity: This is inherent to the problem domain itself. We can’t eliminate it; we need to understand and model it effectively.
- Accidental Complexity: This is the extra complexity we add through our solution. It’s the complexity we want to minimize.
What is Domain-Driven Design?
DDD isn’t just a set of patterns or tools—it’s more of a philosophy, a way of thinking about software design. Think of it as a centered set rather than a bounded one: the more we align with its principles, the closer we get to its core ideas.
The fundamental principles are:
- Start with the Core Domain: Understand the problem space you’re working in
- Build Shared Understanding: Involve all stakeholders
- Develop Ubiquitous Language: Create a shared, precise vocabulary
- Create Shared Models: Express the problem space clearly
- Embrace Essential Complexity: Don’t shy away from the domain’s inherent complexity
- Define Bounded Contexts: Separate models where they make sense
- Iterate Continuously: Keep refining as understanding deepens
The Power of Models
A domain model isn’t just a diagram or a class structure—it’s a system of abstractions that describes selected aspects of a domain. Think of the London Tube map: it’s a model that helps passengers navigate the system, but it’s useless for maintenance engineers who need a different model with different details.
This was one of my key takeaways from the workshop: different perspectives need different models, and that’s okay. What matters is that each model serves its purpose effectively.
What We Built: A Domain Worth Modeling
The workshop revolved around implementing a price calculation engine for a recycling center—something that, at first glance, might seem fairly straightforward. But that illusion didn’t last long.
Every session added new constraints or edge cases, just like in real-world domains. One week we were calculating basic fees. The next, we had to handle customer exemptions. Then came the twist: certain materials had special rules, or pricing logic depended on previous visits or customer types. In a way, it was like the domain was alive—constantly evolving, like real software does.
What I appreciated was how the workshop framed complexity as something that grows organically, and how DDD gives us the tools to contain it, isolate it, and talk about it clearly.
Enter the Subdomains
As the domain grew, we noticed natural boundaries forming between different areas of responsibility. Price calculation was at the core, but it had dependencies:
- Customers (individuals vs. businesses, with different rules)
- Visits (when and how waste was dropped off)
- Invoicing (generating the final bill)
These other areas were modeled as separate contexts, and we made a conscious effort to abstract their quirks instead of letting them spill into our core domain logic.
When Reality Breaks Your Model
One particular moment that stuck with me was when we learned this new rule:
“Business customers may have multiple employees, but pricing exemptions apply to the business as a whole, not the individual who drops off the garbage. Oh, and to identify a business, we have to match address and customer type from an external system.”
It sounds messy—because it is. But that’s the real world. And it showed me why domain boundaries and Anti-Corruption Layers (ACLs) matter so much. The price calculation domain doesn’t care how other systems identify businesses. That’s not its problem.
What is its problem? Whether or not a specific drop-off should be priced differently.
This kind of encapsulation was one of the most eye-opening parts of the workshop for me. And it made me realize: good modeling isn’t about perfection—it’s about containment.
🏗️ Domain-Centered Architecture
Before diving into specific patterns, it’s crucial to understand how DDD influences our architecture. Instead of traditional layered architectures where dependencies can become tangled, DDD promotes a domain-centered approach:
- The domain layer contains our core business logic and models
- All dependencies point towards the domain layer
- The domain layer remains pure, free from external concerns
- Infrastructure and application layers depend on the domain, not vice versa
This architectural choice means our domain logic stays clean and testable, while accidental complexity (like HTTP requests or database operations) lives where it belongs—outside the domain.
🧰 Tactical Patterns: Building Blocks of DDD
The big realization for me was how Tactical DDD gave us structure and language. Instead of dumping logic in a service class or scattering it across controllers, we had patterns to lean on. Each concept had its place, here are some key patterns that transformed how I think about domain modeling:
Domain Objects
At their core, domain objects are modeling concepts expressed in code. But they’re more than just data containers—they’re part of our ubiquitous language and have two crucial characteristics:
- Always Valid: Domain objects guard their own constraints and ensure they’re always in a valid state
- Behavior-Rich: Operations that belong to the concept live with the concept
Value Object
A Value Object models a concept that is defined only by its attributes, not by a unique identity. It’s immutable and interchangeable when all its properties are equal.
In our case, Weight
was a perfect example. It represented a quantity and had operations like sum
, but no concept of identity. It lived and died based on its content.
public class Weight {
private final double units;
public Weight(double units) {
if (units < 0) throw new IllegalArgumentException("Weight cannot be negative");
this.units = units;
}
public Weight sum(Weight other) {
return new Weight(this.units + other.units);
}
public double units() {
return units;
}
}
Entity
An Entity has a distinct identity that runs through its lifecycle, even as its attributes change. It matters who it is, not just what it holds.
Our Visitor
class was a great example of this. Even if the visitor’s address or type changed, we still needed to know it was the same individual (or business).
public class Visitor {
private final String id;
private final String address;
private final String cityId;
private final VisitorType type;
private final String email;
}
Domain Service
A Domain Service encapsulates domain logic that doesn’t naturally belong in an Entity or Value Object. It often orchestrates behavior between multiple domain objects.
Our DroppedFractionService
calculated total prices by combining multiple rules. It didn’t belong to Visitor
or Visit
, so it lived in its own service.
public class DroppedFractionService {
public TotalPrice calculateTotalPrice(Visitor visitor, Visit visit) {
// Coordination logic here
}
}
Repository
A Repository is a layer between the domain and data access, allowing us to retrieve and store aggregates without leaking infrastructure concerns into the model.
For instance, VisitorAPIRepository
helped us fetch Visitors from an external API, while keeping that API out of the domain’s concern.
public class VisitorAPIRepository implements VisitorRepository {
private final APIClient client;
public Optional<Visitor> findUserById(String id) {
// Maps API response into domain Visitor
}
}
Wrapping Up (For Now)
There’s a lot more to say about Domain-Driven Design, and this was just the start. In this post, I wanted to show how tactical patterns—Value Objects, Entities, Services, and Repositories—helped me structure a messy pricing problem into something that felt sharp, testable, and actually reflective of the real domain.
The real win, though? Communication. Once the model matched how we talked about the domain, conversations got easier. During our weekly reviews, we were speaking the same language—not a technical one, but the domain’s own.
This was just a small overview of these concepts. In future posts, I’ll dive deeper into each one and others.
This workshop has helped me transform my code from “it works” to “it expresses,” and I’m looking forward to writing more about it soon.