Every software project that survives long enough eventually faces a reckoning with its core model. That central set of classes, aggregates, or services—the place where business rules live—either becomes a solid foundation or a tangled mess that slows every feature. The difference often comes down to a handful of early architectural choices. This guide identifies the most common core app model pitfalls and offers concrete strategies to avoid them, drawn from patterns that teams repeatedly find effective.
We focus on the decision points that matter most: how abstract to make the model, when to split or merge components, and how to keep the model aligned with real-world product evolution. If you're starting a new project or untangling a legacy core, the frameworks here will help you make deliberate, informed choices.
Who Must Choose and By When
The core app model is rarely a single decision—it emerges from a series of choices made by architects, tech leads, and senior engineers during the first few sprints. But the most critical window is the first month of a new project, or the first two weeks of a major refactor. During this period, the team defines the boundaries of the domain model, selects the primary abstraction pattern (e.g., rich domain model vs. anemic service layer), and decides on the persistence strategy.
Why such a tight timeline? Because every subsequent feature and test assumes the shape of that initial model. Changing the core later means rewriting large swaths of business logic and realigning the database schema—work that most stakeholders are reluctant to fund. The pressure to deliver features quickly often pushes teams to skip careful modeling, leading to what we call the "prototype trap": a model that works for the first three features but collapses under the weight of the fourth.
A common mistake is waiting until the architecture is "stable" before modeling the core. In practice, stability never arrives; the product evolves, and the model must evolve with it. The key is not to freeze the model early but to build in flexibility from the start. That means choosing abstractions that isolate business rules from infrastructure concerns, using interfaces that can be swapped out, and keeping aggregates small enough to change independently.
Another pitfall is assigning model ownership to a single person without team alignment. When the core model is designed in isolation, it often reflects one person's mental model of the domain, which may not match how the rest of the team understands the business. This leads to confusion in code reviews, inconsistent naming, and resistance to change. The solution is to involve at least two senior engineers in the initial modeling sessions, and to document the key decisions in a lightweight architecture decision record (ADR) that the whole team can reference.
Teams that succeed in this phase treat the core model as a shared language—not just a technical artifact. They invest in a few days of domain modeling workshops, using techniques like event storming or domain storytelling, to surface the true business events and rules before writing any code. This upfront investment pays for itself many times over by reducing rework and misunderstandings later.
When to Make the Choice
The ideal time to define the core model is after the team has a clear understanding of the top three user workflows. If you try to model before understanding the workflows, you risk building abstractions that don't match reality. If you wait too long (past the first few sprints), you'll have to retrofit the model onto existing code, which is often more painful than starting fresh.
The Landscape of Approaches
There is no single "correct" way to structure a core app model. The right approach depends on the complexity of your domain, the size of your team, and the expected rate of change. We'll outline three common approaches, each with its own trade-offs.
Rich Domain Model (DDD-Style)
In this approach, the core model consists of aggregates that encapsulate both data and behavior. Business rules live inside the domain objects, and the application layer orchestrates interactions between aggregates. This pattern works well when the domain is complex and has many invariant rules that must be enforced consistently. The main pitfall is over-engineering: teams sometimes create dozens of small aggregates and value objects for a domain that could be served by a simpler design. The result is a model that is hard to navigate and slow to change.
To avoid this, start with a single aggregate per bounded context, and only split when you have a concrete reason—such as a performance bottleneck or a need for independent scaling. A good rule of thumb is that an aggregate should fit on a screen: if you need to scroll through hundreds of lines to understand it, it's probably too large.
Anemic Service Layer with Data Classes
Many teams start with this approach because it feels familiar: data classes (or records) hold state, and separate service classes contain all the business logic. This is often the fastest way to ship an initial version, but it has a well-known cost: the business logic becomes scattered across services, making it hard to see what rules apply to a given entity. As the system grows, services become bloated and tightly coupled, leading to what we call the "god service" anti-pattern.
The key to making this approach work is to keep services small and focused on a single responsibility, and to move business rules into the data classes as soon as they require validation or computation. A pragmatic strategy is to start with an anemic model but commit to refactoring toward a richer model as soon as you see duplication across services.
Event-Driven Core with CQRS
For systems that need high auditability, temporal queries, or integration with multiple downstream consumers, an event-sourced core model can be powerful. Instead of storing current state, you store a sequence of events that represent changes to the domain. The current state is derived by replaying events. This approach makes it easy to answer questions like "what was the state on a given date?" and enables features like event-driven microservices. However, it introduces significant complexity: event versioning, eventual consistency, and the need for a robust event store.
The most common mistake here is adopting CQRS and event sourcing for a domain that doesn't need it. If your business rules are simple CRUD operations, the overhead of event sourcing will slow you down without providing tangible benefits. Reserve this approach for domains where the history of changes is a first-class concern—such as financial transactions, compliance tracking, or collaborative editing.
Criteria for Choosing the Right Approach
When evaluating which core model approach to adopt, consider these five criteria. Each one helps you weigh the trade-offs in your specific context.
Domain Complexity
How many business rules and invariants does your domain have? If the rules are simple (e.g., create, read, update, delete with minimal validation), an anemic service layer may be sufficient. If the rules are complex and involve multiple entities, a rich domain model will help keep the rules in one place. For domains with complex state transitions that must be auditable, event sourcing is worth considering.
Team Size and Experience
A rich domain model requires that every team member understands the domain and the aggregate boundaries. If your team is large or has high turnover, the learning curve may be steep. In that case, a simpler approach with clear service boundaries may be more maintainable. Conversely, a small, experienced team can leverage a rich model to move fast without introducing chaos.
Expected Rate of Change
If the domain is stable (e.g., a legacy system that rarely changes), the model can be more rigid. If the domain is evolving rapidly (e.g., a startup iterating on product-market fit), you need a model that can be refactored easily. In the latter case, keeping aggregates small and using interfaces between layers can help isolate changes.
Performance and Scalability Requirements
Event sourcing and CQRS can introduce latency due to eventual consistency and event replay. If your system requires immediate consistency for most operations, a traditional CRUD model with a rich domain layer may be a better fit. On the other hand, if you need to scale reads independently from writes, CQRS can be advantageous.
Operational Overhead
Every pattern comes with operational costs. Event sourcing requires an event store and event versioning. A rich domain model requires careful testing of invariants. An anemic model requires discipline to prevent service bloat. Evaluate the operational maturity of your team: can they handle the complexity of event versioning? Do they have the tooling for automated testing of domain logic?
To make the decision easier, we recommend creating a simple weighted scorecard. List the five criteria, assign a weight (1–5) based on your project's priorities, and score each approach (1–5) on how well it meets each criterion. The approach with the highest total score is a good starting point, but be prepared to adjust as you learn more.
Trade-Offs at a Glance
The following table summarizes the key trade-offs between the three approaches. Use it as a quick reference during design discussions.
| Criterion | Rich Domain Model | Anemic Service Layer | Event-Driven / CQRS |
|---|---|---|---|
| Domain complexity handling | Excellent | Poor (rules scatter) | Good (but complex) |
| Learning curve | Medium–High | Low | High |
| Change flexibility | Good (if aggregates small) | Moderate (services can be refactored) | Good (events decouple) |
| Auditability | Low (only current state) | Low | Excellent |
| Performance (reads) | Good (direct queries) | Good | Excellent (projections) |
| Operational overhead | Medium | Low | High |
| Best for | Complex, stable domains | Simple CRUD, small teams | Audit-heavy, event-driven systems |
Notice that no approach wins on all criteria. The art is in matching the approach to your project's specific constraints. For example, if you have a complex domain but a large team with high turnover, you might choose a hybrid: use a rich domain model for the core aggregates but keep the service layer anemic for peripheral features.
Common Pitfall: Ignoring the "When Not to Use"
One of the biggest mistakes teams make is adopting a pattern because it's trendy, without considering whether it fits their domain. We've seen teams implement CQRS for a simple blog engine, only to abandon it after months of fighting with eventual consistency. Similarly, teams sometimes force a rich domain model onto a domain that is essentially CRUD, resulting in unnecessary complexity. Always ask: "What problem does this pattern solve for us?" If you can't articulate a clear benefit, start simpler.
Implementation Path After the Choice
Once you've chosen an approach, the next step is to implement it in a way that avoids common pitfalls. The following path is designed to be iterative, allowing you to validate the model before committing to a full implementation.
Step 1: Define the Bounded Context
Start by identifying the boundaries of your core domain. Use event storming or a similar technique to map out the business events and commands. Define the aggregates within each bounded context, keeping them as small as possible while still enforcing invariants. Document the context map to show how contexts interact via events or service calls.
Step 2: Build a Walking Skeleton
Implement the smallest possible end-to-end flow that touches the core model. This might be a single command that creates an aggregate and persists it. The goal is to validate that the model works in practice—that the aggregate boundaries are correct, that the persistence strategy works, and that the model can be tested. This walking skeleton should be deployable and demonstrate the core value proposition.
Step 3: Write Tests for Invariants
For each aggregate, write unit tests that verify the business rules. These tests should be independent of infrastructure—they should not hit the database or call external services. This gives you a safety net for refactoring. A common pitfall is writing tests that are too coarse (integration tests only) or too fine (testing getters and setters). Focus on the rules that would break the business if they were wrong.
Step 4: Refactor Based on Real Usage
After the walking skeleton is in production (or at least in staging), monitor how the model is used. Are there aggregates that are loaded together frequently? Consider merging them. Are there aggregates that are rarely used together? Consider splitting them. The key is to let real usage drive the structure, not upfront speculation. We recommend scheduling a model review after the first two or three features are built, and again after every major release.
Step 5: Document the Decision Log
Keep a running log of architectural decisions related to the core model. For each decision, note the context, the options considered, the chosen approach, and the reasons. This log is invaluable when new team members join or when you need to revisit a decision months later. Use a simple format like Architecture Decision Records (ADRs) stored in your repository.
Risks of Choosing Wrong or Skipping Steps
Choosing the wrong core model approach—or skipping the modeling steps altogether—can have serious consequences. Here are the most common risks and how they manifest.
Risk 1: The Big Ball of Mud
Without a clear core model, business logic ends up scattered across controllers, services, and even views. Changes to one feature break another, and the team becomes afraid to refactor. This is the most common outcome when teams skip upfront modeling and just start coding. The cost of untangling a big ball of mud later is often higher than the cost of building a proper model from the start.
Risk 2: Over-Engineering and Analysis Paralysis
On the opposite end, teams that spend too long modeling without writing code risk over-engineering. They create a model that is too abstract, with many layers and indirections that make simple changes cumbersome. The model becomes a barrier to delivery rather than an enabler. The solution is to time-box the initial modeling to a few days and start coding a walking skeleton as soon as possible.
Risk 3: Inconsistent State Due to Poor Aggregate Boundaries
If aggregates are too large, they become a performance bottleneck and a source of contention. If they are too small, you lose the ability to enforce invariants across related entities. The risk of inconsistent state increases when aggregates are split incorrectly, leading to bugs that are hard to reproduce. A common example is an Order and OrderLine split into separate aggregates without a mechanism to enforce that the total amount matches the sum of line items.
Risk 4: Resistance to Change from Stakeholders
When the core model is not aligned with how the business thinks about the domain, stakeholders may resist changes because the model doesn't reflect their mental model. This leads to friction in requirements gathering and acceptance testing. To mitigate this, involve domain experts in the modeling sessions and use the ubiquitous language from Domain-Driven Design to ensure that the model uses the same terms as the business.
If you skip the implementation steps—especially testing and refactoring—the model will drift from the actual needs of the system. Over time, developers will work around the model rather than through it, adding hacks that defeat the purpose of having a model at all.
Frequently Asked Questions
How do I know if my core model is too large or too small?
A good heuristic is the "change impact" test: if a change to one entity requires changes in many unrelated places, your aggregates may be too large. Conversely, if a single business operation requires updating many small aggregates in a transaction, they may be too small. Aim for aggregates that change together and are loaded together in the most common use cases.
Should we use an ORM or raw SQL for the core model?
It depends on the complexity of your queries. ORMs can simplify mapping but may hide performance issues. For a rich domain model, an ORM with lazy loading can lead to the N+1 problem. Raw SQL gives you control but adds mapping code. A pragmatic approach is to use an ORM for simple CRUD and raw SQL for complex queries, keeping the repository interface clean.
How do we handle cross-cutting concerns like logging and auditing in the core model?
Keep cross-cutting concerns out of the domain model. Use decorators, middleware, or aspect-oriented programming to add logging and auditing at the application layer. The domain model should be pure business logic, free from infrastructure concerns. This makes it testable and reusable across different application contexts.
What's the best way to migrate from an anemic model to a rich one?
Start by identifying the most critical business rules that are currently duplicated across services. Move those rules into the data classes one at a time, ensuring that all code paths use the new encapsulated logic. Write tests for each rule before moving it. This incremental approach reduces risk and allows you to validate each change.
Is event sourcing worth it for a small team?
Generally, no. Event sourcing adds significant complexity in event versioning, eventual consistency, and debugging. Only consider it if your domain requires auditability or temporal queries, and if your team has experience with the pattern. For most small teams, a simpler approach will be more productive.
After reading this guide, the next step is to assess your current project's core model against the criteria we've outlined. Identify one area where the model feels fragile or misaligned, and plan a small refactor to improve it. Start with a single aggregate—redefine its boundaries, move business logic inside, and write tests for its invariants. Even this small change can have a noticeable impact on maintainability and team confidence.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!