You’ve finally escaped the distributed systems hell of microservices, consolidated your domain logic into a modular monolith eating microservices’ lunch, and drawn crisp boundaries around your business capabilities. Then you hit the wall that makes senior architects weep: how do modules actually talk to each other?
The specific scenario is maddeningly common. Your subscription module needs to verify a branch exists and fetch its ID, schedule, and coordinates. Meanwhile, your billing module only needs the branch ID and name. Do you expose one fat GetBranch method that returns everything, including the kitchen sink, and let consumers ignore what they don’t need? Or do you maintain separate methods for every use case, optimizing database queries but exploding your API surface into a maintenance nightmare?
This isn’t just a code style debate. It’s a fundamental tension between performance and coupling, and most teams solve it by picking the poison that kills them slowest.
The Fat DTO Trap: Convenience at the Cost of Performance
The siren song of the fat DTO is strong. One method, one query, one contract to maintain. Your BranchService exposes a single getBranch(Long id) returning a BranchDetails DTO with 47 fields, and every module takes what it needs.
The Problem: Database over-fetching isn’t a theoretical sin, it’s a performance tax that compounds. When your subscription module queries for a branch existence check, it’s joining six tables and hydrating entities it immediately discards. In a high-throughput system, this “convenience” translates to wasted memory, slower response times, and database connection pool exhaustion.
Worse, fat DTOs create implicit coupling through data gravity. When the BranchDetails DTO changes because one module needs a new field, every consumer must recompile, retest, and redeploy. You’ve traded distributed service calls for distributed breaking changes within the same process.
The Granular Method Trap: Death by a Thousand Endpoints
Swinging the pendulum to the other extreme, you might create getBranchForSubscription, getBranchForBilling, getBranchForReporting, and getBranchForMobileApp. Each returns a perfectly tailored, minimal data transfer object. No over-fetching, no wasted bytes.
The Consequence: Congratulations, you’ve invented internal microservices without the network latency but with all the versioning hell. One experienced practitioner noted that this approach inevitably leads to “tens of flavors of DTOs”, creating a diffuse mix of partial data structures that confuse developers and fragment caching strategies. When you need to add a field, you don’t update one DTO, you update seventeen slightly different variants, hoping you caught all the usages.
This granular approach also signals a deeper architectural rot: your modules are too chatty. If your subscription module constantly needs to ask the branch module for different data shapes, the boundary is probably in the wrong place. As one sharp observation from recent architecture discussions put it: if your protocol is chatty, “fat dto vs multiple methods are going to be similarly badly designed.” The real fix is moving responsibilities so less data has to move across the wire, or in this case, across the package boundary.
The Chatty Protocol Problem
The fat vs. granular debate is often a symptom of trade-offs in inter-module communication that we refuse to acknowledge. When modules constantly exchange rich data objects, it’s usually because we’ve created artificial layers in the name of “clean architecture” without considering actual data flow.
Drawing a parallel to networked services helps clarify the cost: each contract has weight. In a distributed system, you’d eventually throw protobuf at the problem and accept the serialization cost. In a modular monolith, we forget that internal contracts require the same maintenance discipline. Every method exposed through your contract layer is a promise you’re making to other modules, and in-process evolution beats cross-service coordination precisely because we can refactor these boundaries without breaking external consumers, unless we’ve painted ourselves into a corner with brittle contracts.
A Taxonomy That Actually Works: The Four DTOs
Rather than oscillating between bloated god-objects and hyper-specific query methods, successful teams standardize on a constrained vocabulary of contract types. Based on patterns observed in high-performance systems, you typically need exactly four shapes:
1. The Full Aggregate (Domain Entity)
Everything that belongs to the same domain aggregate. This is your “fat” DTO, but it’s used sparingly, only when the consuming module truly owns the lifecycle or needs the complete graph.
2. The Lookup/Reference
Three to four fields maximum: ID, name, status, and perhaps a type discriminator. This is your cheapest DTO to load, used for existence checks, dropdown selectors, and “if exists” logic. It should be your default choice.
3. The List Item
A standardized summary shape for collections. If you’re regularly displaying branch summaries in tables or API lists, define one canonical BranchSummary rather than letting each feature invent its own variant.
4. The View/Extended
The escape hatch. Used when performance demands combining data across domain lines or calculating aggregates at the database level. These are explicitly named (e.g., BranchWithInventoryCounts) and treated as expensive, cache-unfriendly operations.
This taxonomy prevents the “DTO explosion” while avoiding over-fetching. Consistency means a developer can guess the contract name and be right without digging through the codebase.
Enforcing Boundaries with Spring Modulith
If you’re working in the JVM ecosystem, Spring Modulith provides concrete tooling for this problem. It treats your contract layer as a first-class architectural concern, not just a folder of interfaces.
Instead of package-by-layer (controllers/, services/, repositories/), you organize by package-by-module (catalog/, orders/, inventory/). Within each module, you explicitly expose what other modules can see using @NamedInterface:
@NamedInterface("order-models")
package com.example.orders.domain.models;
import org.springframework.modulith.NamedInterface;
Or you define a provided interface in the root package:
@Service
public class CatalogApi {
private final ProductService productService;
public Optional<Product> getByCode(String code) {
return productService.getByCode(code);
}
}
Spring Modulith’s ModularityTest then enforces that no module depends on internal types, preventing the gradual leakage that turns modular monoliths into big balls of mud. This approach forces you to be intentional about your contract layer rather than letting it emerge accidentally from package-private visibility.
The Maintenance Weight of Contracts
The crucial insight many teams miss: contracts have maintenance costs that dwarf the initial implementation time. When debating whether to add another specialized endpoint or extend a fat DTO, ask whether you’re solving a real performance problem or creating an existential one.
If your database can handle the query, and the data transfer isn’t crossing a network boundary, the “over-fetching” might be cheaper than the cognitive load of seventeen different GetBranch variants. Don’t hyper-optimize or introduce too many moving parts until profiling proves you have a genuine bottleneck.
This is where Shopify’s modular monolith migration offers a sobering lesson. They didn’t succeed by creating perfect, granular APIs from day one. They succeeded by maintaining strict internal boundaries while accepting that some contracts would be imperfectly sized until extraction became necessary.
Decision Framework: When to Break the Rules
So which approach wins? Neither. The answer depends on your module’s stability and access patterns:
Use Fat DTOs (Sparingly): When the data represents a true aggregate root that changes together, and consumers genuinely need 80% of the fields. Think Order with its OrderItems, splitting these creates more problems than it solves.
Use Granular Methods: When performance metrics prove over-fetching is expensive, and the data shapes diverge significantly between use cases. If one module needs a branch’s geolocation coordinates while another only needs the address string, separate methods prevent loading spatial data unnecessarily.
Refactor Immediately: When you find yourself creating GetBranchForMobileAppV2 or GetBranchWithExtraFieldsForReporting. This is the DTO explosion smell. Consolidate back to the four-type taxonomy and question whether the requesting module should even own that data.
Conclusion
The modular monolith contract layer isn’t a place for architectural purity, it’s a place for ruthless pragmatism. Fat DTOs and granular methods aren’t opposing ideologies, they’re tools that fail when applied dogmatically.
Standardize on a constrained vocabulary of contract types, enforce boundaries with tools like Spring Modulith, and remember that startups defaulting to monoliths made the right call for complexity management, but only if they resist the urge to build premature microservices traps inside their own codebase.
Final Rule: The goal isn’t perfect DTO sizing. It’s ensuring that when you inevitably revert from complexity back to simplicity, your contract layer doesn’t fight you every step of the way. Keep it simple, keep it intentional, and for the love of all that is holy, don’t return 47 fields when you only need three.


