Someone draws six boxes on a whiteboard, someone else says the split "feels wrong", a third person says it "looks clean", and the meeting closes on whoever argued longest. Nobody lied, but nobody measured anything either. Intuition is fine as a starting point. It's a lousy way to settle an argument about a system that five teams will live inside for the next four years.

The good news is that a domain model can be judged with metrics. Not all of them are numbers, and none of them work everywhere, but they turn "I don't like it" into "this change touches four modules and three teams", and you can actually talk about that.

Can you measure whether a domain model is good?

Yes, but there are four separate skills hiding in that question, and teams usually get stuck on the last two.

The first is simply knowing that a model has measurable properties beyond your sympathy for it. Most engineers in reasonably mature organizations already accept that. The second is knowing what those properties are: coupling between modules, blast radius of a failure, how many commands compete for the same invariant, how often an integration fails, and so on. The third is the hard one: deciding which of those metrics matter for your business, your product and your code, and which ones you can ignore. You will never optimize for all of them at once. The fourth is working out how to actually measure the metric you picked, because "modifiability" means nothing until you know how to test it.

No model scores well on every metric, and that applies to everything below. Every design choice buys something and pays for it with something else, so a review that only lists what a model gains is half a review.

Which metrics matter at the strategic level?

At the strategic level you are looking at the whole map: the modules, the teams that own them, and the arrows between them. The metric that almost everything else reduces to here is autonomy, which in practice means modifiability.

The test question is blunt. If a new requirement lands tomorrow, how many modules do we have to change, and how many teams do we have to coordinate to change them? Several modules owned by one team is annoying but cheap. Several modules owned by three teams is expensive, because now you pay for meetings, priority negotiation and waiting until another team finds room in their sprint. That waiting is what stretches lead time from days into weeks.

This isn't only a hunch. DORA research has kept pointing at loosely coupled architecture as one of the capabilities behind strong software delivery performance, and delivery performance in turn shows up in organizational outcomes. When you protect module autonomy, you protect those delivery metrics.

Autonomy also means more than one thing. There is autonomy of change and release, where one team ships without waiting for another. There is failure autonomy: when one module breaks, the checkout still works, carts can still be built, and only one slice of the business process is down. And there is technology autonomy, where one module can use a graph database or a functional core because its problem needs it, even though the other 99% of the codebase would never touch that tool.

Don't treat autonomy as the goal, though. It's a proxy. The real goals are shorter lead time for changes, a smaller blast radius when something goes wrong, and a system that still fits in the heads of the people who work on it.

How to pick change scenarios that actually test the split

The standard way to measure modifiability is to simulate change: write down plausible future requirements (anti-requirements, if you like the term) and walk each one through the current split. I covered that method in an engineering approach to modularity, so here I want to focus on which scenarios you pick, and why.

Two engineers can run the same exercise and get opposite answers only because they imagined different futures. So the choice of scenarios needs some rationale, even if it's never going to be math.

Start by asking which parts of the domain are generic and stable, and which are specific to how this company makes money. Delivering goods, sending notifications or issuing invoices are fairly generic. Companies do them in similar ways, and the ways change rarely. When they do change (parcel lockers, drones), the whole market adapts at once. Pricing, promotions, loyalty rewards and complaint handling are different. There are many ways to do them, they are often where the competitive advantage lives, and they change constantly.

That observation gives you a design direction and a scenario generator at the same time. The specific parts should adapt to the generic ones, never the other way around. And because the specific parts are where the business will keep experimenting, that's where you generate most of your scenarios. Add a new promotion type, a new reward, a new kind of refund, and watch whether the generic modules have to change. If they do, the dependency points the wrong way.

The second input is the company's strategy. You can't pick good scenarios without understanding the domain and knowing in which direction the organization plans to grow. Generate ideas along that axis. And don't wait for the future: plenty of splits are already tangled by today's requirements, so run the exercise on the backlog you have right now as well.

How to read the arrows between modules

Once you have scenarios, the most mechanical and most underrated check is to look at the arrows. You can erase every noun from the diagram and still learn a lot.

Picture an online learning platform. A Certificates module listens for CourseCompleted from Courses. Then a bootcamp product appears, so it also listens for BootcampGraduated. Then exams, then webinars. Every new product that grants a certificate forces the certificates team to add another listener and learn another module's vocabulary. The architecture isn't plug and play. A new module on top doesn't work until someone edits a module at the bottom, and that team becomes a bottleneck for everybody else's roadmap.

The number alone isn't a verdict. Analytics listening to many modules is usually fine. It's a read model, it only collects facts, and adding a new producer doesn't change any decision it makes. Certificates is different, because it has to interpret each event to decide whether a certificate is due. So for every high fan-in module, ask a second question: does a new upstream module force a code change here? If yes, you've found a dependency that points the wrong way.

Beyond the count and the direction, look at three more properties of an arrow. Frequency matters for performance: an arrow that fires thousands of times per second is a different design problem than a nightly batch. Depth matters for coupling: does the arrow hit a public contract, or reach straight into the module's internals? And watch for pass-through modules that receive a call only to forward it to the next module without doing anything, which usually means somebody drew a box for the sake of drawing it.

The fix for the certificates case is to let the generic module define its own language. Certificates publishes a command in its own terms, and every product that grants certificates speaks that language:

Now Certificates doesn't care whether someone finished a course, passed an exam or survived a bootcamp. A new product just sends IssueCertificate, and nobody touches the certificates module. If your team prefers events over commands, the same idea works with a generic fact such as AchievementEarned instead of one event per product. The shape of the contract matters more than the messaging style. The pattern has a name, open host service, and I went deeper into it in how to avoid Siamese twin modules.

The good version costs about the same to build as the bad one. Either way it's one message class, one channel and one handler. The effort goes into choosing between the options, not into typing the code, so "we had no time to do it properly" doesn't really hold up here. A useful follow-up when someone reaches for that excuse is to ask what they would change with twice the time. Surprisingly often the answer is nothing, because they already built the best version they knew how to build.

What does a distributed database look like on the diagram?

There's a second mechanical smell you can spot without knowing anything about the business. Draw the business process on a wall and mark every important question asked along the way. An important question is one the user asks because they want to see something, or one the system asks because it has to decide which path to take.

Now mark where the data for each answer comes from. If most questions reach into five different modules, gather a little data from each and compute something locally, you have two problems. The logic of computing that answer is duplicated in every place that asks, so a rule change means editing the same if in several modules. And every answer depends on the availability of all those sources, so one module going down takes most of the process with it.

This design usually comes from slicing the process vertically into noun-named boxes, giving each box its own database, and sprinkling data across them. What you get is a distributed database with network calls instead of joins. There's an old joke that seniors got bored of joining tables, so now they join microservices. I wrote more about how that facade modularity rots in pseudo-modularity.

Gathering data from several places isn't always wrong. A reporting dashboard does exactly that on purpose. The warning sign is decision-making logic that has to gather data from many modules in many places.

When should you ignore these strategic metrics?

Picking metrics also means dropping some. A migration system that will be switched off in three months doesn't need to be modifiable, however complicated it is. Don't spend a week tuning its boundaries.

Team shape changes the measurement too. If two developers own the whole thing in a monorepo, the certificates listener problem is almost negligible compared to ten teams spread across time zones. Modifiability still matters, but you measure it differently: can these two people still hold the system in their heads? The catch shows up when one of them leaves, or when the team grows to five and the newcomers don't happen to share the original pair's mental map. A structure where the specific parts adapt to the generic ones is simply easier to fit in a head.

And be realistic about scale. In a system with thirty modules, getting twenty-eight boundaries right is a very good outcome.

How to measure a single module: Start with the problem class

Zoom into one box. Inside there's a model solving one problem, wrapped in API, persistence and messaging code. Picking metrics at this level gets much easier once you name the class of problem the model solves, because each class has its own relevant metrics and its own useless ones. Four classes cover most cases: contention over shared state, data transformation, integration and presentation.

One metric is useless almost everywhere: lines of code. Let's see why.

Contention: How many commands block each other?

A contention problem shows up when many users act on the same resource at the same time and a business rule protects it. Seats can't be oversold, and a shared budget can't go below zero. DDD calls the consistency boundary for that an aggregate. The metric that matters is how many business commands have to meet at the same boundary because they compete for the same invariant.

class WorkshopSeatPool
  NotEnoughSeats = Class.new(StandardError)

  def initialize(capacity:)
    @capacity = capacity
    @holds = {}
  end

  # Every public command guards one invariant: taken seats never exceed capacity
  def hold(attendee_id, seats)
    raise NotEnoughSeats if available < seats

    @holds[attendee_id] = @holds.fetch(attendee_id, 0) + seats
  end

  def release(attendee_id)
    @holds.delete(attendee_id)
  end

  def transfer(from_id, to_id)
    seats = @holds.delete(from_id) or return

    @holds[to_id] = @holds.fetch(to_id, 0) + seats
  end

  def shrink_capacity(new_capacity)
    raise NotEnoughSeats if taken > new_capacity

    @capacity = new_capacity
  end

  private

  def taken = @holds.values.sum

  def available = @capacity - taken
end

All four commands read or change the same two pieces of state, and running any two of them concurrently without coordination could break the rule. A hold racing a capacity reduction is the classic oversell. So they belong together, and in a real app they would share one lock, for example an optimistic lock_version column on the pool's row.

If the analysis finds fifteen commands you can't generalize and that all protect the same invariant, you want fifteen methods in one class. You want to see them in one place. Splitting them into five classes because "the class is too big" hides the one thing that matters, which is that they all fight over the same rule. The useful metric goes the other way: a command that doesn't touch the invariant, such as renaming an attendee's badge, doesn't belong in the aggregate at all, because it only adds lock contention.

Transformation: How many independent reasons to change?

Calculations, pricing procedures and data pipelines take input and produce output without guarding shared state. Here size can mean something, but only indirectly. If a procedure has natural steps and those steps change for different reasons, extracting them makes sense. The better metric is to list the directions in which the procedure changes. If the tax rules change on a different schedule than the discount rules, that points to injectable policies. If everything changes together, a plain method with a few if statements is honest and fine.

Integration: What happens when the other side fails?

If you have to connect fifty payment gateways, you'll write fifty integrations. No clever pattern turns that into one, so line count and abstraction depth tell you nothing. What matters is operational behavior: response times, what happens when a call fails, when and how often you retry, whether the retry is remembered or fire-and-forget, what delivery guarantee you give (at-least-once is the usual honest answer), and how many conflicts still end up on a human's desk for manual resolution. Track that last number over time. It's the most direct measure of whether the integration model fits reality.

Presentation: How fast, and how far from normalized?

Read models have different priorities. Nobody wants a home page that takes three seconds to load, while a spinner for two seconds during a cinema seat reservation is perfectly acceptable. That's the contention side paying for consistency. So response time dominates here, and the database shape follows it. Write models for contention problems tend to live close to third normal form, read models are often fully denormalized, and "how far are we from each" is a legitimate metric for each class.

Don't forget algorithmic complexity

Big O notation turns out to matter at the model level too. A classic failure is storing a graph in a relational table with a foreign key pointing back to the same table. It's easy to build and works perfectly on a test database with a few hundred rows, and then the production reports take hours because every level of the hierarchy is another round of queries. The model decision set the complexity, and no amount of indexing saved it. The same goes for choosing an aggregate root: that choice decides whether sharding the data later is easy, hard or impossible.

Why you should build at least three alternative models

The habit that changes model reviews more than any single metric is refusing to evaluate one model in isolation. Build at least three, ideally four, before you judge any of them.

With one model, you get emotionally attached to it and defend it like a fortress, because criticism of the idea feels like criticism of you. Two models give you a dilemma, and three or more give you real options. Once you have several on the table, you can compare them coldly with the metrics you picked, the way an engineer weighs the consequences of each design.

Be precise about what counts as a different model. Saving the same structure to a file, a document store or a relational database isn't three models. Storage is secondary. Different models express different relationships and different truths. An order that contains its lines is one model. A line that references its order header is another. Recording only the fact that a line was added to an order is a third, with its own properties. If sketching three of those takes you days, that's a signal worth thinking about.

Sometimes the problem class really does force a single model. A stateless integration that maps XML to JSON and forwards it, with no sagas and no memory, doesn't leave room for fundamentally different models. You can arrange the classes differently or choose different libraries, but the model stays the same. Recognize those cases and don't waste time polishing them. The same goes for code that runs once a month or goes to the bin in a quarter: good enough is the right target.

For those cases, the metrics move to after the decision. Watch the change failure rate of the module over time, and watch the delivery pipeline. If reorganizing the code to separate infrastructure from domain logic cuts the test feedback loop from two hours to three minutes, a slightly more complex class structure is a price worth paying many times over.

That's the balance you need to keep. Every reorganization improves some metrics and quietly damages others. "More testable" is the classic one-sided argument: it may buy two extra test cases nobody needed while making the code harder for the next person to read. Readability is a subjective metric, and your own design always feels perfectly readable to you. So for every change, ask three questions. Which metric does it improve? Which one does it hurt? Which one matters more here?

Watch out for the second system effect as well. If a previous project burned you on one specific problem, it's tempting to pour all your attention into proving you can solve that problem properly this time, while five other things quietly go wrong. The old wound isn't necessarily the key problem of the new system.

What if the business itself doesn't know how it works yet?

Sometimes there's no domain expert to learn from, because the business is still discovering its own rules. In Cynefin terms, you're in complex or chaotic territory rather than the ordered kind. In that situation the worst move is building a refined, general, beautifully tested model on top of guesses. The polish only makes you more confident in something you invented.

Build the simplest thing that works, even a cascade of if statements, ship it, and let production teach both you and the business. When the rules settle and there's someone to talk to, you build the better model.

That approach only works under one condition: the strategic split has to be sound. If the boxes are drawn well and the signals going in and out of them are stable, you can rewrite everything inside a box, switch the model, the language or the storage, and nobody outside notices. Tactical decisions are cheap to reverse when the strategic ones are right. Strategic mistakes are the ones that stay with you.

A checklist for your next model review

  1. Name the metrics you care about, and say which ones you're deliberately ignoring and why.
  2. Write change scenarios from the business-specific side of the domain and from the company's strategic direction.
  3. For each scenario, count the modules touched and the teams that need to coordinate.
  4. Map the arrows: count, direction, frequency, depth and pass-through modules.
  5. Mark important questions on the process and check how many modules each answer reaches into.
  6. Classify each module's problem (contention, transformation, integration, presentation) and pick class-specific metrics.
  7. Sketch at least three alternative models before choosing, and write down what each one gives up.

FAQ

Is lines of code a useful metric for a domain model?

Almost never. An aggregate that protects one invariant with fifteen competing commands should have fifteen methods, and fifty integrations will always be fifty integrations. Size only starts to mean something in transformation code, and even there the number of independent reasons to change tells you more.

How many alternative models should I sketch before choosing?

At least three, ideally four. Two alternatives give you a yes-or-no dilemma, while three or more let you compare trade-offs. A different model means different relationships or different recorded facts, not a different storage engine.

Does a module with many event listeners always mean bad coupling?

No. Read models such as analytics or reporting are supposed to listen to many sources. The problem is a module that has to change its code every time a new upstream module appears, because it has to interpret each producer's vocabulary to make decisions.

Do these metrics matter for a small team in a monorepo?

Some matter less. Coordination cost between teams disappears when there's only one team, so measure cognitive load instead: can the current people hold the design in their heads, and could a new person pick it up? Keeping specific parts adapted to generic ones helps with both.

Happy modeling!