cognitive-horizon
MMXXVI
Architecture · · By Art

Domain-Driven Design Without the Jargon

Domain-Driven Design has a reputation problem. Mention DDD in most teams and you'll get one of two reactions: eyes glazing over at the thought of Bounded Contexts and Aggregate Roots, or enthusiasm for a methodology that's been cargo-culted into an unrecognizable bureaucratic process. The actual ideas behind DDD are simple and powerful. This article strips away the ceremony and gets to the essence — heavily inspired by Scott Wlaschin's brilliant work at F# for Fun and Profit.

What we'll cover

  1. The one idea behind DDD that actually matters
  2. Making illegal states unrepresentable
  3. Types as documentation (the Wlaschin approach)
  4. Bounded Contexts without the enterprise overhead
  5. Where DDD goes wrong in practice

The One Idea That Actually Matters

Strip away the patterns, the diagrams, and the consulting frameworks, and DDD comes down to one insight: your code should speak the same language as your domain.

That's it. When a domain expert says "a customer places an order," your code should have a Customer that places an Order. Not a UserEntity that calls TransactionService.process() with a RequestDTO. The names in your code should match the names the business uses. The relationships in your code should mirror the relationships in the domain. When you read the code, you should be able to understand the business process — even if you've never seen the codebase before.

This sounds obvious, but look at most enterprise codebases. They're full of AbstractServiceManagerFactoryImpl — names that describe the code's technical role, not the domain concept it represents. DDD says: stop doing that. Name things after what they mean in the domain, not what they are in the code.

Making Illegal States Unrepresentable

This is where Scott Wlaschin's influence is strongest, and it's the most practical idea in all of DDD. The principle: design your types so that invalid domain states cannot be constructed.

Consider an email address. Most codebases represent it as a String. That means any function that accepts an email also accepts "hello", "", or "DROP TABLE users". You end up sprinkling validation everywhere — in controllers, in models, in services — and you inevitably miss a spot.

The DDD approach: create an EmailAddress type that can only be constructed from a valid email string. Validation happens once, at the boundary. Every function that accepts an EmailAddress knows it's already valid. You can't accidentally pass a raw string where an email is expected.

This principle scales to complex domain states. Consider an order that can be in various states: draft, submitted, paid, shipped, cancelled. With a single Order class and a status field, nothing prevents you from setting a shipping address on a cancelled order or applying a payment to a draft. With separate types — DraftOrder, SubmittedOrder, PaidOrder — each type only exposes the operations that make sense for that state. The compiler enforces your business rules.

Types as Documentation

Wlaschin's key insight, explored extensively in "Domain Modeling Made Functional," is that a well-designed type system IS the documentation. When your types accurately model the domain, reading the type definitions tells you the business rules.

Compare these two approaches to modeling a shopping cart:

The typical approach

class Cart
  attr_accessor :items, :status, :discount_code,
                :shipping_address, :payment_method
end
# Any combination of fields is valid. Business rules
# are scattered across service objects and callbacks.

The domain-driven approach

EmptyCart = Data.define()
ActiveCart = Data.define(:items)  # items is NonEmptyList
ReadyToCheckout = Data.define(:items, :shipping_address)
PlacedOrder = Data.define(:items, :shipping_address,
                          :payment_confirmation)

# The type itself tells you: you can't checkout without
# a shipping address. You can't place an order without
# payment. An active cart always has at least one item.

The second version requires no comments, no wiki page, no onboarding document to explain the rules. The types ARE the rules. A new developer reading these type definitions immediately understands the business process.

This works in any language with a reasonable type system — not just F# or Haskell. Ruby has Data.define, TypeScript has discriminated unions, Kotlin has sealed classes, Rust has enums. Even in dynamically typed languages, you can apply the principle by creating small, focused classes that enforce invariants in their constructors.

Bounded Contexts Without the Enterprise Overhead

"Bounded Context" is the DDD term that causes the most confusion. Here's what it actually means: the same word means different things in different parts of your business, and your code should reflect that.

A "customer" in the sales department is a lead with contact info and a purchase history. A "customer" in the shipping department is a delivery address and a phone number. A "customer" in the billing department is a payment method and an invoice history. They're all called "customer," but they have different data and different behaviors.

The mistake most codebases make: one giant Customer model with every field from every context. It knows about payments AND shipping AND sales AND support tickets. Every team adds their fields, every change risks breaking someone else's code, and the model becomes a 2,000-line God object.

The bounded context approach: each part of the system has its own model of a customer, with only the data it needs. The sales context has Sales::Customer with leads and opportunities. The shipping context has Shipping::Recipient with addresses. They share a customer ID for correlation but are otherwise independent.

You don't need a microservice boundary for this. A module, namespace, or package boundary works fine. The point isn't the deployment unit — it's the modeling discipline.

Where DDD Goes Wrong in Practice

DDD fails when teams treat it as a set of patterns to implement rather than a way of thinking about their domain. The classic symptoms:

Pattern stuffing. Creating Repositories, Aggregates, Value Objects, Domain Events, and Specification patterns for a CRUD app with three models. DDD patterns exist to solve specific modeling problems. If your domain is simple, your code should be simple. A Rails ActiveRecord model is fine for most things.

Premature boundaries. Splitting into bounded contexts before you understand the domain. You'll draw the lines in the wrong places and spend months refactoring. Start with a well-structured monolith, let the natural boundaries emerge from the code, then formalize them.

Ignoring the language. The whole point of DDD is speaking the domain's language, but many teams implement all the technical patterns while still naming things DataProcessor and EntityManager. You're doing DDD wrong if your domain experts can't read your class names and nod.

Forgetting the tests. If your domain model is well-designed, your tests should read like business specifications. "When a paid order is shipped, the customer receives a tracking notification." If your tests are full of mocks and setup boilerplate, your model probably isn't capturing the domain well.

Key Takeaways

  • 1. DDD is fundamentally about one thing: your code should speak the same language as your domain. Everything else is a technique for achieving that.
  • 2. Make illegal states unrepresentable. Design your types so that invalid domain states can't be constructed. Validate at the boundary, trust the types internally.
  • 3. Types are documentation. A well-designed type definition tells you the business rules without needing comments or external docs.
  • 4. Bounded Contexts are about modeling discipline, not deployment topology. The same word means different things in different parts of the business — let your code reflect that.
  • 5. DDD fails when treated as a checklist of patterns. Apply it where the domain is complex enough to warrant it, and keep things simple where the domain is simple.
  • 6. For deeper study: Scott Wlaschin's "Domain Modeling Made Functional" is the best practical introduction. It uses F# but the ideas apply to any language with a type system.

Wrestling with domain complexity?

We help teams design domain models that make complex business logic clear and maintainable.