Remember 2018? If you pitched a new project back then without a Kubernetes cluster, a service mesh, and a dozen-plus microservices, you were practically laughed out of the architecture review. We were promised independent deployments, infinite scalability, and a future with no technical debt just clean little services, each minding its own business.
Fast forward to today, and the hangover has set in. Teams are drowning in distributed tracing dashboards just to find out why a request takes 4 seconds. Engineers are chasing a single bug across five repositories, three on-call rotations, and one very confused Slack thread. And a lot of these systems are serving a few thousand users not the millions they were architected for. The industry is slowly admitting what a few people were saying all along: jumping straight into a distributed system on Day One is usually a mistake, not a sign of maturity.
But the fear of the alternative hasn't gone anywhere. Nobody wants to go back to the "big ball of mud" the kind of monolith where touching the payment logic somehow breaks the notification engine, and nobody's entirely sure why.
Now add AI coding assistants to this picture, and things get messier in a new way.
Point an AI assistant at a sprawling microservices estate, and it struggles not because it's "afraid" of complexity, but because it simply can't hold a dozen services' worth of contracts, queues, and network boundaries in its context window at once. It'll guess. It'll miss a contract. It'll confidently change a field name without realizing three other services depend on it.
Point that same assistant at a traditional, unstructured monolith, and it has the opposite problem: too much access, not too little. It'll reach across folders that have nothing to do with each other. It'll write a SQL join straight across two domains because the tables happened to be sitting right there. Ask it to "add a discount field," and it might just add a foreign key from orders into users because, technically, that's the fastest way to make the test pass. AI doesn't think about architectural integrity. It thinks about making the current diff green.
So we're stuck between two failure modes: microservices are too complex too early, and monoliths at least the kind most of us inherit are a breeding ground for exactly the kind of debt AI is good at generating quickly.
The fix isn't to pick the lesser evil. It's to build something in between: a Modular Monolith, structured with Clean Architecture. Enforce real, physical boundaries inside one codebase, and you get the operational simplicity of a monolith today, a tightly fenced sandbox where AI can actually do good work, and if you ever need it a much shorter path to pulling a piece out as its own service.
Here's how to build one that's designed to be taken apart.
The Root Mistake: Your Database Is Your Architecture
You can draw the cleanest module diagram in the world. Boxes for Orders, Inventory, Billing. Arrows pointing the right way. It'll look great in Confluence.
None of it matters if your database doesn't agree with the diagram.
This is the part teams get wrong most often: they enforce boundaries in their folder structure and ignore them completely in their schema. A repository in the Orders module reaches directly into the inventory_items table because, well, it needed the stock count and writing a join was faster than calling another module's interface. Multiplied across a few dozen tickets, you end up with a users table that fifteen unrelated modules read from and write to directly no interface, no contract, just shared mutable state with extra steps.
Here's the test that actually tells you whether your architecture is real: stop looking at your folder names, and look at your foreign keys. Your schema is the most honest documentation your system has. It doesn't care what the diagram says.
This is also exactly where "extract this into a microservice" projects go to die. The code-level coupling is usually fixable in days. Untangling years of cross-domain joins and shared tables figuring out who actually owns a piece of data, migrating it, and rewriting every query that assumed it could just reach across is the part that turns a two-week extraction into a six-month death march.
The Fix: Bounded Contexts as the Real Unit of Modularity
Borrow a term from Domain-Driven Design here, because it's the right one: a bounded context. Not a layer. Not a folder. A bounded context is a slice of your domain Orders, Billing, Inventory that owns its own data and exposes everything else through an explicit interface: a method call, a published event, a defined contract. Nothing else.
The rule is simple to state and uncomfortable to enforce: no module touches another module's tables. Ever. Not through a shared ORM model, not through a "quick" join, not through a view that quietly bridges two schemas. If Orders needs something from Inventory, it asks through code, not through SQL.
If this sounds suspiciously like microservices without the network calls, that's because it basically is. You're choosing to pay the organizational cost of real boundaries now, while deliberately deferring the operational cost of microservices deployment pipelines, service discovery, network failure handling until you actually have a reason to pay it.
# inventory/domain/ports.py
# The ONLY way another module is allowed to ask Inventory for anything.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass(frozen=True)
class StockLevel:
sku: str
available_quantity: int
class InventoryQueryPort(ABC):
"""Public contract for the Inventory bounded context.
Other modules depend on THIS, never on Inventory's internals."""
@abstractmethod
def get_stock_level(self, sku: str) -> StockLevel:
...
# orders/application/place_order.py
# Orders depends on the Inventory PORT, not on Inventory's database,
# models, or internal services. It doesn't even know Inventory uses Postgres.
from inventory.domain.ports import InventoryQueryPort
class PlaceOrderUseCase:
def __init__(self, inventory: InventoryQueryPort):
self._inventory = inventory
def execute(self, sku: str, quantity: int) -> bool:
stock = self._inventory.get_stock_level(sku)
if stock.available_quantity < quantity:
return False
# ... proceed with order creation
return True
Notice what PlaceOrderUseCase can't do: it can't run a query against Inventory's tables, because it never imported anything that would let it. The boundary isn't a convention written in a wiki page somewhere it's a Python import that simply doesn't exist.
Clean Architecture Inside Each Module
The previous section was about boundaries between modules. This one's about structure inside each one and this is where Clean Architecture's actual core idea earns its keep: the dependency arrow always points inward.
Your business rules the actual logic of "what does placing an order mean" sit at the center, and they know nothing about Postgres, FastAPI, or any other framework. The outer layers your database adapter, your web framework, your ORM depend on the core. The core never depends on them.
# orders/domain/entities.py
# Pure business logic. No imports from FastAPI, SQLAlchemy, or anything
# that knows what a database or HTTP request even is.
from dataclasses import dataclass
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
REJECTED = "rejected"
@dataclass
class Order:
sku: str
quantity: int
status: OrderStatus = OrderStatus.PENDING
def confirm(self) -> None:
if self.quantity <= 0:
raise ValueError("Cannot confirm an order with zero quantity")
self.status = OrderStatus.CONFIRMED
# orders/application/ports.py
# The use case depends on an ABSTRACTION of persistence,
# not a concrete database.
from abc import ABC, abstractmethod
from orders.domain.entities import Order
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
...
# orders/infrastructure/postgres_order_repository.py
# The concrete implementation lives on the OUTSIDE,
# and depends INWARD on the port. The core never imports this file.
import psycopg2
from orders.application.ports import OrderRepository
from orders.domain.entities import Order
class PostgresOrderRepository(OrderRepository):
def __init__(self, connection: psycopg2.extensions.connection):
self._conn = connection
def save(self, order: Order) -> None:
with self._conn.cursor() as cur:
cur.execute(
"INSERT INTO orders (sku, quantity, status) VALUES (%s, %s, %s)",
(order.sku, order.quantity, order.status.value),
)
self._conn.commit()
The payoff is concrete, not academic: swap Postgres for DynamoDB, or FastAPI for Flask, and Order and PlaceOrderUseCase don't change a single line. More importantly for this article's whole premise when it's time to extract Orders into its own service, its core logic already doesn't know it's living inside a monolith. You're not rewriting business rules. You're rewriting an adapter.
Where AI Thrives and Where It Quietly Breaks Your Boundaries
This is where the two threads of this article meet.
Inside a bounded context, AI is genuinely excellent. Ask it to generate a new use case, write a repository implementation, scaffold tests for PlaceOrderUseCase it'll do it fast, and because the blast radius is one module with a handful of files, even its mistakes are cheap to catch and fix. This is AI at its best: a narrow, well-defined sandbox with a clear contract at the edges.
Across bounded contexts, AI has no idea where your boundaries are unless something physically stops it. Ask an assistant to "show the user's order history along with their current loyalty points," and a model with no architectural constraints will often do the statistically obvious thing: reach into both tables in one query, because that's the shortest path to a green test. It's not being careless. It's optimizing for "make this work," and nothing in a typical prompt or repo told it that working isn't the only requirement.
# What an unconstrained AI assistant will often generate when asked to
# "show order history with loyalty points" a direct cross-module join:
def get_order_history_with_points(user_id: str, db):
# orders and loyalty_points are owned by TWO DIFFERENT bounded contexts.
# This works. It also quietly deletes your boundary.
return db.execute("""
SELECT o.id, o.sku, o.quantity, lp.points_balance
FROM orders o
JOIN loyalty_points lp ON lp.user_id = o.user_id
WHERE o.user_id = %s
""", (user_id,))
# The corrected version Orders asks Loyalty through its public port,
# the only crossing point that's allowed to exist:
from loyalty.domain.ports import LoyaltyQueryPort
from orders.domain.ports import OrderRepository
class GetOrderHistoryWithPoints:
def __init__(self, orders: OrderRepository, loyalty: LoyaltyQueryPort):
self._orders = orders
self._loyalty = loyalty
def execute(self, user_id: str) -> dict:
history = self._orders.get_by_user(user_id)
points = self._loyalty.get_balance(user_id)
return {"orders": history, "loyalty_points": points.balance}
Same outcome for the end user. Completely different architectural reality. The second version is slightly more code and it's the only version that survives the day you decide Loyalty needs to become its own service.
The thesis for this section, stated plainly: AI is a fantastic resident inside a bounded context, and a terrible architect of the boundaries between them because nothing about how these models work makes them optimize for a constraint you didn't enforce.
A few ways to make that enforcement real, not aspirational:
Import linting tools like
import-linter(Python) or dependency-cruiser (JS/TS) can fail a build the momentorders/imports anything frominventory/internal/.CI as the actual boundary, not the README. If the only thing stopping a cross-module import is a sentence in a wiki page, assume it will eventually be crossed by a human or a model.
Make the port the only thing that's importable. If a module's internals simply aren't exposed outside its own package, an AI assistant can't accidentally reach for them there's nothing there to reach for.
The Payoff: What This Buys You When It's Time to Split
Here's the moment the title promised. Say Orders has outgrown the monolith maybe it needs to scale independently, maybe a separate team owns it now. Because its core logic never depended on anything outside its own boundary, extraction looks like this:
# Before: in-process call, still inside the monolith
class PlaceOrderUseCase:
def __init__(self, inventory: InventoryQueryPort):
self._inventory = inventory # in-process implementation
# After: extracted to its own service ONLY the adapter changed
class PlaceOrderUseCase:
def __init__(self, inventory: InventoryQueryPort):
self._inventory = inventory # now an HTTP/gRPC client implementing
# the exact same port interface
PlaceOrderUseCase doesn't change. Order, the entity, doesn't change. The only thing that changes is what sits on the other side of InventoryQueryPort an in-process Python object yesterday, an HTTP client today.
Worth being honest about what this doesn't solve. You're still going to deal with distributed transactions, network failures, and the cost of running another service in production. This approach doesn't make those problems disappear it just defers them to the point where you've actually earned the need to solve them, instead of solving them speculatively on day one.
Closing: Boundaries Are a Discipline, Not a Diagram
The team that says "modular monolith" and actually means it isn't the one with the nicest box-and-arrow diagram. It's the one whose database schema, codebase, and CI pipeline all agree on where the boundaries are and enforce them the same way, whether the next pull request comes from a human or a model.
AI assistants don't change that calculus. They multiply it. Point one at a codebase with real boundaries, and it'll write fast, contained, disposable code inside them all day. Point one at a codebase without them, and it'll write the cross-domain shortcut just as fast and just as confidently.
The architecture decides which one you get.
This is, in a lot of ways, the architectural sibling of the AI technical debt I wrote about in my last piece debt you pay down once, structurally, instead of forever, one code review at a time.
Enjoyed this breakdown? I write about architecture decisions like this modular monoliths, bounded contexts, and where AI helps vs. where it quietly breaks things in my newsletter, AI + Architecture.





Top comments (0)