From Package-by-Layer to Domain-Partitioned: What I Actually Learned
Jul 23, 2026
"It depends" is the honest answer to almost every architecture question, and it's also the most useless one if you stop there. So here's the longer answer — what it depends on, why each style tends to break down the way it does, and what the migration actually looked like in practice.
This isn't a controlled study. It's one migration, on one production backend, and what I noticed doing it. I'll also be upfront about something I got wrong in an earlier draft of this post: I framed it as "layered vs. domain-partitioned," like they're two competing answers to the same question. They're not. The domain modules I ended up with still have a router, a service, and a repository inside each one — so I didn't get rid of layers, I just changed what the top-level boundary is. The real comparison is package-by-layer vs. package-by-domain. Worth getting that straight before anything else.
Part 1: Package-by-Layer — how it actually works
The idea
You organize code by what kind of thing it is, not what it does for the business:
presentation/ ← HTTP routing, request parsing, response formatting
├── user_routes.py
├── order_routes.py
└── billing_routes.py
business/ ← application orchestration and business rules
├── user_service.py
├── order_service.py
└── billing_service.py
data/ ← persistence, SQL, ORM mapping, transactions
├── user_repository.py
├── order_repository.py
└── billing_repository.py
Some versions of this insist dependencies only point downward — presentation depends on business depends on data. Other versions flip the data layer around with dependency inversion, so business code depends on a repository interface and the real implementation gets plugged in from outside. Point is, "layered architecture" isn't one specific rulebook. It's a family of setups that all share the same basic instinct: group by technical role.
Why it's genuinely fine for a while
I'm not going to pretend this was a mistake from day one, because it wasn't. Early on it gave me real advantages:
You always know where something goes. HTTP handling, business logic, or a SQL query — the decision is almost automatic.
Testing is straightforward. Mock the repository, test the service. Hit a real DB, test the repository. This only holds up if the layer interfaces stay focused, though — I've seen "the repository layer" turn into a junk drawer just as easily as anything else.
Shared infrastructure lives in one place. Connection pooling, retries, transaction handling — write it once in the data layer, everyone gets it. Just don't let that shared layer quietly become the place where everyone dumps whatever doesn't fit elsewhere. That's its own trap.
Everyone already knows this pattern. New person joins, they already have a mental model. That's worth something.
Where it starts to hurt
None of what follows is inevitable. It's just what tends to happen if nobody's watching.
Feature scatter, and the "shotgun surgery" thing people say too casually
A single feature ends up spread across a route file, a service file, a repository file, maybe a schema file too. Change the feature, touch all of them.
I used to call this "shotgun surgery," and honestly that's overselling it. Fowler's actual shotgun surgery smell is about a change rippling through a bunch of unrelated modules because the responsibility for that one thing is scattered with no clear home. Touching three files that all belong to the same feature and get reviewed together in one PR isn't that — it's just... a feature that touches three layers. Annoying, sure. Not the same failure mode.
And switching to package-by-domain doesn't make this problem vanish either — auth changes, logging changes, a shared schema migration will still cut across everything no matter how you've organized your folders.
Anemic domain models
Fowler's term for this: objects that are just data bags, with all the actual behavior living in service classes instead of on the objects themselves. It happens a lot in layered codebases because every rule tends to land in *_service.py by default.
But — and I had to correct myself on this — layering doesn't cause this. You can absolutely build a rich domain model inside a layered structure; you just need an actual model layer with entities and value objects that carry their own logic. And you can just as easily end up with anemic domains in a domain-partitioned codebase if every domain folder is still just data classes plus a service that does everything. This is a modeling choice, not a folder-structure consequence. I conflated the two before, and that was just wrong.
Horizontal coupling nobody's watching
The "dependencies only point down" rule says nothing about services calling each other. billing_service imports user_service imports order_service imports billing_service — congratulations, you have a cycle, and the layered structure did nothing to stop it.
There's a decent way to measure this, if you want numbers instead of vibes:
- Afferent coupling (Ca) — how many things depend on this module
- Efferent coupling (Ce) — how many things this module depends on
- Instability (I) —
Ce / (Ca + Ce)
I got this wrong in an earlier version of this post, so let me say it correctly: I ranges from 0 to 1. 1 is maximally unstable — everything about that module can change and nothing protects the modules relying on it. 0 is maximally stable. A value near 0.5 just means the incoming and outgoing coupling are roughly balanced — it's not some danger zone, I just misread the metric.
Worth knowing too: the ratio hides magnitude. A module with Ca=1, Ce=1 gets the same I=0.5 as one with Ca=100, Ce=100, and those two modules are not remotely the same risk. Look at the raw counts too, not just the ratio.
Files that grow until they're unreadable
A 1,200-line route file is a real problem, but it's not some law of nature that layered architectures must produce these. You can split a big feature by use case or workflow and still keep your technical layers intact — nothing stops you. Domain-first packaging gives you a more obvious seam to split along by default, sure. But "the file got huge" is ultimately a decomposition failure, and it can happen in either style if nobody splits things up.
Part 2: Domain-Partitioned — how it actually works
The idea
Sometimes called package-by-feature, package-by-domain, or a modular monolith if it's all one deployable. You organize the top level by business capability instead of technical role — but each domain still has its own internal layers:
domains/
├── users/
│ ├── router.py # HTTP adapter
│ ├── service.py # application orchestration
│ ├── domain.py # entities, value objects, policies
│ ├── repository.py # persistence
│ ├── schemas.py # transport contracts
│ ├── models.py # ORM mappings this domain owns
│ ├── dependencies.py # FastAPI providers
│ ├── api.py # the published interface other domains can call
│ └── events.py # published event contracts
│
├── orders/
│ └── ...
├── billing/
│ └── ...
├── notifications/
│ └── ...
└── admin/
├── router.py
└── read_models/ # owns projection tables, not the source-of-truth ones
kernel/ # small, narrow, shared infrastructure
├── db/
├── config/
├── security/
├── messaging/
└── observability/
Nobody agrees on the folder names, and that's fine. What actually matters is that the boundaries and the allowed dependencies between them are explicit somewhere, not just implied.
The three constraints I actually used
I want to be careful here: these are the specific choices I made for this migration, not some universal law of domain partitioning. You can do this differently.
1. Each domain owns its own data — no exceptions, no cross-domain joins
Every domain owns the source-of-truth tables for its own model. Nothing else writes to them directly. That alone stops schema changes in one domain from silently breaking something in another.
I went a step further and banned cross-domain reads too, which means no joins like this from billing:
-- not allowed under this boundary policy
SELECT b.amount_minor, u.email
FROM billing_invoices AS b
JOIN users AS u ON b.user_id = u.id
WHERE b.status = 'pending';
Billing has to either call the users domain's published interface to get what it needs, or maintain its own copy of the data via a read model.
I should be honest that this is a policy choice, not a hard requirement of domain partitioning in general. Plenty of modular monoliths allow controlled read-side joins or database views while still locking down writes. Whether that's right for you depends on how much independence you actually need versus how much a join would simplify your life.
One more honesty check: an import linter can catch a forbidden ORM import, but it has no idea if someone wrote raw SQL, called a stored procedure, or did something dynamic/reflection-based to get around it. If you actually need this enforced hard, you're looking at combining import rules with database-level permissions, separate DB roles per domain, and probably some SQL static analysis on top — not just a linter. And if you hear someone say "table-ownership linter" like it's an off-the-shelf tool, ask what it actually is, because as far as I know that's custom-built, not something you install.
2. Domains talk through published interfaces, and translate what they get
# domains/users/api.py
from uuid import UUID
from .schemas import UserPublicProfile
from .service import get_profile
async def get_user_profile(user_id: UUID) -> UserPublicProfile:
"""The published interface. Other domains can call this — nothing else."""
return await get_profile(user_id)
Everything else inside users/ — the repository, the internal service functions, the ORM models — stays private.
When another domain consumes this, it translates the result into its own concept of what a "user" means to it:
# domains/billing/acl/users.py
from dataclasses import dataclass
from uuid import UUID
from domains.users import api as users_api
@dataclass(frozen=True)
class BillingUserContext:
user_id: UUID
email: str
account_type: str
async def get_billing_user_context(user_id: UUID) -> BillingUserContext:
profile = await users_api.get_user_profile(user_id)
return BillingUserContext(
user_id=profile.id,
email=profile.email,
account_type=profile.plan_tier,
)
That translation step is the anti-corruption layer. It doesn't make the coupling disappear — billing still needs users to exist and respond. What it does is keep billing's internal model clean instead of letting users' concepts leak in directly. The coupling is still there. It's just contained and visible instead of silent.
3. Actually enforce it, don't just write it in a doc
Rules that live only in a wiki page rot. People forget, deadlines happen, someone takes a shortcut "just this once." I used Import Linter to catch violations in CI. One thing I got wrong the first time I wrote a contract for this: I accidentally forbade billing from importing its own repository, which makes no sense — a domain obviously needs its own internals. Here's the corrected version, which only blocks billing from reaching into other domains' repositories:
# .importlinter
[importlinter]
root_package = domains
[importlinter:contract:billing-must-not-import-other-repositories]
name = Billing must not import other domains' repositories
type = forbidden
source_modules =
domains.billing
forbidden_modules =
domains.users.repository
domains.orders.repository
domains.notifications.repository
You'd need one of these per domain, not just one overall. And I want to be honest about the ceiling here too: this is deterministic for exactly what it checks, and nothing more. Dynamic imports, raw SQL, generated code, wrong ownership metadata — none of that gets caught. It's a real safety net, just not an infinite one.
Cross-domain writes: you don't always need events
Here's something I got wrong in an earlier pass — I made it sound like the moment you cross a domain boundary, you're suddenly dealing with a distributed transaction and you must reach for events and an outbox. That's not true. If everything's still going through one database and one local transaction, it's a completely normal local transaction, regardless of how many domain "services" got called along the way to build it.
The actual problem — the one that needs something more careful — is when you have to coordinate two independent resources atomically. A database write and a message broker publish. A database write and an email send. That's a dual-write problem, and there are several legitimate ways to handle it: synchronous orchestration with retries, a saga, idempotent commands, or a transactional outbox. Outbox is just the one that fits "I need to reliably publish something after a DB commit," it's not the universally correct answer to every cross-domain write.
Domain events vs. integration events
Worth keeping these separate in your head. A domain event is something that happened inside a domain. The moment you publish that across a boundary for someone else to consume, it becomes an integration event — and that distinction matters, because integration events are contracts other people depend on, so you can't just reshape them whenever you feel like refactoring internally.
# domains/billing/events.py
from dataclasses import dataclass
@dataclass(frozen=True)
class PurchaseCompletedV1:
event_id: str
user_id: str
plan_id: str
invoice_id: str
amount_minor: int
currency: str
occurred_at: str # ISO 8601 UTC
Notice amount_minor as an integer, not a float. Money as a float is a classic way to get quietly wrong numbers from binary rounding errors — integer minor units (or Decimal, if you define the scale carefully) is the boring, correct choice.
The transactional outbox, written correctly this time
The pattern: write your actual business record and an outgoing event record in the same database transaction. Either both commit or neither does.
I had actual bugs in the version of this code I first wrote — mixing a sync Session type with async syntax, reading an ID before it existed, using a deprecated datetime call. Here's the corrected version:
# domains/billing/service.py
from dataclasses import asdict
from datetime import UTC, datetime
from uuid import UUID, uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from .events import PurchaseCompletedV1
from .models import Invoice, OutboxEvent
from .pricing import get_plan_price
async def complete_purchase(
user_id: UUID,
plan_id: UUID,
db: AsyncSession,
) -> UUID:
async with db.begin():
price = await get_plan_price(plan_id=plan_id, db=db)
invoice_id = uuid4()
event_id = uuid4()
occurred_at = datetime.now(UTC)
invoice = Invoice(
id=invoice_id,
user_id=user_id,
plan_id=plan_id,
amount_minor=price.amount_minor,
currency=price.currency,
status="paid",
)
integration_event = PurchaseCompletedV1(
event_id=str(event_id),
user_id=str(user_id),
plan_id=str(plan_id),
invoice_id=str(invoice_id),
amount_minor=price.amount_minor,
currency=price.currency,
occurred_at=occurred_at.isoformat(),
)
outbox_record = OutboxEvent(
id=event_id,
event_type="billing.PurchaseCompleted.v1",
payload=asdict(integration_event),
occurred_at=occurred_at,
)
db.add_all([invoice, outbox_record])
return invoice_id
Small things that actually matter here: AsyncSession now matches the async with syntax I'm using. The IDs are generated up front with uuid4() instead of hoping the ORM assigns one before I need it. And it's datetime.now(UTC), not datetime.utcnow() — that one's deprecated now and returns a naive datetime with no timezone info attached, which is exactly the kind of thing that causes bugs three months later when you least expect it.
The relay that actually publishes these reads committed rows that haven't been processed yet — I mistakenly called them "uncommitted" before, which doesn't even make sense, since the whole point of the pattern is that they're already safely committed by the time anything reads them:
# kernel/messaging/outbox_relay.py
async def relay_outbox_batch(limit: int = 50) -> None:
events = await claim_pending_outbox_events(limit=limit)
for event in events:
try:
await broker.publish(
topic=event.event_type,
message_id=str(event.id),
payload=event.payload,
)
except Exception:
await release_or_record_failure(event.id)
continue
await mark_published(event.id)
A few things a real production version needs that I'm not going to pretend this snippet handles: safe claiming so two relay workers don't grab the same row, actual retry/backoff, dead-letter handling, ordering guarantees if you need them. And you should assume this is at-least-once, not exactly-once — if the process dies after publishing but before marking the row done, that event goes out again. Whatever's consuming it needs to be idempotent, usually by just remembering which event IDs it's already handled.
The outbox gets rid of direct runtime coupling between billing and whoever's listening. It doesn't get rid of semantic coupling — they still both depend on that event contract meaning the same thing. And "asynchronous" doesn't mean "milliseconds later." An outage or a retry storm can turn that into minutes or hours.
Read models for the queries that genuinely need multiple domains
CQRS, in its actual original form, just means: separate the model or interface you use for writing from the one you use for reading. That's it. It doesn't require a separate database, denormalization, event sourcing, or async updates — those are things people commonly pair with CQRS at scale, not requirements of the pattern itself. I'd overstated this before.
For one admin query that genuinely needed data from three different domains, I did end up building a denormalized projection:
# domains/admin/read_models/user_activity_summary.py
from datetime import datetime
from sqlalchemy import DateTime, JSON, String
from sqlalchemy.orm import Mapped, mapped_column
class UserActivitySummary(Base):
__tablename__ = "admin_user_activity_summary"
user_id: Mapped[str] = mapped_column(String, primary_key=True)
email: Mapped[str] = mapped_column(String)
plan_tier: Mapped[str] = mapped_column(String)
last_order_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
total_spend_minor_by_currency: Mapped[dict[str, int]] = mapped_column(JSON)
Admin owns this table. It does not own the actual users, billing, or orders tables — I said "admin owns no tables" earlier in this same post and then contradicted myself later. To be precise: admin owns its own projections, never the source of truth.
Event handlers keep this table updated from the integration events flowing in. Which means the dashboard is eventually consistent, and you have to actually decide: how stale is acceptable, how do you rebuild this if it gets corrupted, what happens when the event schema changes underneath you. I also claimed this was just "more scalable and more resilient" than a join before — that's not really fair without the caveats. It can absolutely help with query latency and isolating read traffic. It also adds storage, projection code that can break, staleness, and duplicate-event handling you didn't have before. It's a trade, same as everything else in this post.
Part 3: What the migration actually looked like
It wasn't a rewrite, and it didn't need to be a "strangler fig" in the strict sense
You can rewrite a production backend from scratch. People do it. I don't think it's impossible, I just think it's risky, because the old system keeps changing while you're building the replacement, and now you're trying to hit a moving target.
Martin Fowler's Strangler Fig pattern is the usual reference here — replace functionality incrementally behind a stable interface instead of cutting over all at once. I used to describe what I did as strangler fig, but if I'm being precise, that term is usually reserved for replacing an entire system, often across a real system boundary. What I actually did — restructuring code within the same deployable application — has its own name: branch by abstraction. Same underlying instinct (introduce an abstraction, let old and new coexist, migrate callers gradually, delete the old path once nobody's using it), just the more accurate term for staying inside one codebase.
Here's roughly the order I'd actually recommend, with the caveat that these are heuristics, not a script to follow blindly:
-
Map out candidate boundaries first, and expect to be wrong. Look at business capabilities, who owns what, what has to be transactionally consistent, what data depends on what, how things tend to change together. Your first attempt at drawing these lines will probably need revising once you're actually in the code.
-
Pull out shared infrastructure, but keep it small. Config, DB session setup, observability, messaging — things that are genuinely cross-cutting. Don't let
kernel/(or whatever you call it) turn into a dumping ground, because a shared kernel is itself a coupling mechanism, and a bloated one couples everything to everything. -
Start with something cohesive and reasonably low-coupling. I used to say "start with leaf domains," but that's ambiguous unless you've defined which direction dependencies flow. What you actually want is something with clear ownership and a manageable number of things depending on it, so you can build a stable adapter around it without the whole system shifting under you.
-
Add the boundary checks as soon as there's a boundary to check. Don't wait until the "real" migration is done. If you need a temporary exception while you're mid-migration, make it explicit and put a date on removing it.
-
Build the event infrastructure right before you actually need it, not before, not long after. Building the broker/outbox/projection machinery speculatively, before any workflow needs it, is wasted complexity. Waiting until five domains are already doing unreliable dual writes to each other means a much more painful retrofit. The sweet spot is: build it right before the first workflow that genuinely requires reliable async publishing.
What actually changed for me
These are just my own observations from this one migration — not a universal result, just what I noticed.
Finding stuff got easier, mostly. Almost everything about orders — the adapter, the rules, the persistence, the tests, the published contract — lives under orders/ now. Cross-cutting stuff and shared infra still requires stepping outside that folder, that never fully goes away.
Coupling stopped hiding. A call through users.api, a subscription to billing.PurchaseCompleted.v1 — these are visible dependencies now. Before, it was just an unconstrained import that could've been anything. The coupling didn't vanish. It got a name and a place you can actually see it.
CI caught what I told it to catch, and nothing more. Which sounds obvious written down, but I did fall into treating "the build passed" as "the architecture is fine," and that's not quite right. Code review still has to catch the stuff the tooling can't express — raw SQL sneaking past the linter, an API that shouldn't really be public, semantic coupling that no import rule will ever see.
Part 4: The actual trade-off, without picking a side
| Concern | Package by layer | Package by domain |
|---|---|---|
| Getting started | Fewer concepts to decide up front | You have to decide boundaries earlier |
| Finding a feature | Often spread across several files | Usually lives in one place |
| Shared technical stuff | Easy to find, one place | Needs a convention or shared adapter across domains |
| Boundaries | Layer boundaries are obvious; domain boundaries are often just implied | Can be explicit and actually linted |
| Reading across domains | No inherent rule — joins are easy and common | Can go through APIs, controlled joins, or a projection, depending on your policy |
| Writing across domains | No inherent rule — direct calls are normal | Public commands, orchestration, events, or an outbox, again depending on what you need |
| Consistency | Can be strong or eventual | Can also be strong or eventual — this isn't actually tied to the packaging choice |
| Boilerplate | Usually less for simple CRUD | Usually more once you've made contracts and adapters explicit |
| What actually goes wrong | Scatter, and coupling nobody's tracking | Boundary mistakes, duplicated models, coordination overhead |
| Fits best | Simple domains, small teams, technically-driven apps | Complex business rules, distinct ownership, capabilities that change at different speeds |
I want to flag one thing about this table that I got wrong before: I'd tied consistency and communication style directly to the packaging choice, like layered = synchronous/strong and domain-partitioned = async/eventual. That's not actually true — they're separate decisions. You can build a domain-partitioned modular monolith that's entirely synchronous and strongly consistent because it's all one process and one database. And you can bolt async messaging onto a layered system too. Packaging and consistency model are just orthogonal, even though in practice people often change both at once.
Same with "small codebase = layered, big codebase = domain-partitioned" — that's too simple. Line count isn't really the signal. What actually matters more: how tangled the changes are across features, who owns what, how strict your consistency needs are, what your team looks like. A huge but genuinely simple data pipeline can be perfectly happy layered. A small app with gnarly business rules and clearly separate ownership might benefit from domain boundaries early.
The signal that's actually worth watching for: do you keep seeing changes ripple across unrelated packages? Are there dependency cycles? Is change coupling between "unrelated" files suspiciously high? Are reviews slow because nobody can tell what a change will actually touch? That's when the conversation is worth having — weighed honestly against what stronger boundaries cost you: more contracts, more duplicated representations, new async failure modes, more operational surface area.
Where I landed
Package-by-layer and package-by-domain aren't really opposites — you can combine them, and I did, since every domain in my new structure still has its own internal layering. Technical layering is often the cheaper, more sensible option when business boundaries are genuinely fuzzy or the app is simple. Domain-first packaging tends to earn its cost when capabilities have real, distinct rules, ownership, and rates of change.
Neither one hands you good boundaries, a rich domain model, strong consistency, or maintainability for free. You still have to actually do that work either way.
What this migration really was, for me, was a trade: less accidental, invisible coupling, in exchange for more explicit contracts, some duplicated representations, and new coordination machinery I now have to maintain. Whether that trade was worth it isn't a question the architecture answers by itself — it's a question of whether it actually fixed problems I was measurably having.
Sources I leaned on
- Martin Fowler, Anemic Domain Model and Domain Model
- Martin Fowler, Strangler Fig Application
- Microsoft Learn, Applying simplified CQRS and DDD patterns
- Microsoft Learn, Transactional Outbox pattern
- Import Linter, Forbidden contracts
- Python docs,
datetimeanddecimal - SQLAlchemy docs, AsyncIO support and session flushing