Reference guide
Architecture Patterns Explained
What these patterns actually mean, why they matter, and how they work together. No jargon walls. Real code. Plain English.
Domain-Driven Design (DDD)
Organize by business domain, not technical layer
Most tutorials teach you to organize code by technical layer: all models in one folder, all services in another, all routes in a third. It works for small projects but falls apart fast.
Domain-Driven Design flips this. You organize by business concept. Everything related to scheduling (its models, business logic, routes, tests) lives in one folder. Everything related to billing lives in another.
Layer-based (avoid)
models/ (50 models, 5,000 lines)
services/ (30 services, 4,000 lines)
controllers/ (40 endpoints, 3,000 lines)
schemas/ (60 schemas, 2,000 lines) Domain-based (prefer)
domains/scheduling/
models.py (65 lines)
service.py (100 lines)
router.py (40 lines)
tests/ (unit + integration) Why it matters for AI-augmented development
When you point AI at a layer-based project, it has to parse 16,000 lines where only 2-5% is relevant. With domain-based organization, AI sees ~1,000 lines that are 100% relevant. First-try success jumps from 40% to 88%.
Key rules
- No cross-domain imports. Domain A never imports domain B's internals. Shared types go in a
shared/directory. - Service layer owns business logic. Routes are thin wrappers: they receive HTTP, call the service, return a response.
- Events for cross-domain communication. If scheduling needs to notify billing, it publishes an event, not a direct import.
Deep dive: Lesson 5: Domain Boundaries
Event-Driven Architecture
Domains communicate through events, not direct calls
Imagine deactivating a user. You need to cancel their subscription, send a goodbye email, revoke permissions, log to audit trail, update analytics. In a traditional codebase, your UserService directly calls all five services. Eight dependencies. Eight things to mock in tests.
With events, the UserService publishes one fact: "a user was deactivated." It doesn't know or care who listens. Each subscriber handles its own logic independently.
Direct calls (tight coupling)
class UserService:
def __init__(self, db, notifications,
audit, subscriptions, scheduling,
analytics, cache, permissions):
# 8 dependencies Event-driven (loose coupling)
class UserService:
def __init__(self, db, event_bus):
# 2 dependencies
async def deactivate(self, user_id):
await self.event_bus.publish(
UserDeactivated(user_id=user_id)
) You don't need Kafka
A simple in-process event bus (under 50 lines) handles most applications. You only need dedicated message queues when events must survive process restarts or cross service boundaries.
Key rules
- Events are past tense.
UserDeactivated,InvoiceCreated,OrderShipped. They describe what happened, not what should happen. - Events carry IDs, not objects. Keep payloads minimal. Subscribers fetch what they need.
- Subscribers don't affect the publisher. If a subscriber fails, the original action still succeeds. Handle failures in the subscriber.
Deep dive: Lesson 6: Events Over Calls
Monolith-First
Start simple, extract services when you have proof
"Should I use microservices?" is the wrong first question. The right question is: "Do I have proof that any domain needs to scale independently?" If the answer is no (and for most early-stage projects it is), start with a monolith.
A well-structured monolith with domain boundaries gives you 90% of microservice benefits with a fraction of the operational complexity. No service mesh. No distributed tracing headaches. No "which service is failing at 3 AM" puzzles.
When to extract a service
Extract a domain into a separate service only when you have concrete proof of one of these:
Independent scaling
The domain handles 100x more load than the rest of the application.
Different release cadence
The domain needs to deploy independently, multiple times per day.
Different tech stack
The domain requires a language or framework the monolith doesn't use.
Key rules
- API-first within the monolith. Domains communicate through defined interfaces, even inside one process. This makes future extraction straightforward.
- Domain boundaries are the preparation. If you follow DDD and event-driven communication, extracting a domain into a service becomes a deployment change, not a rewrite.
- One repo, multiple domains. Keep everything in a monorepo. Split by domain folder, not by repository.
Related: Lesson 5: Domain Boundaries
Row-Level Security (RLS)
Multi-tenant isolation enforced by the database
In a multi-tenant application, multiple customers (tenants) share the same database. The critical question: how do you make sure Tenant A never sees Tenant B's data?
Most applications solve this with application-level filters, adding WHERE tenant_id = X to every query. But one missed filter, one forgotten check, one developer who doesn't know about it, and you have a data leak.
Row-Level Security is a PostgreSQL feature that moves this check into the database itself. The filter is automatic and cannot be bypassed by application code.
How it works
-- 1. Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 2. Create a policy: filter by current tenant
CREATE POLICY tenant_isolation ON projects
FOR ALL TO app_user
USING (
tenant_id = current_setting('app.current_tenant_id')::uuid
);
-- 3. Set context in your middleware (every request)
SET app.current_tenant_id = 'tenant-uuid-here';
SET ROLE app_user;
-- Now every query is automatically filtered.
-- SELECT * FROM projects returns only this tenant's data. The fail-safe default
What happens when the session variable isn't set? With RLS, the answer is: zero rows returned. No context, no data. This is the opposite of application-level checks where the default is "return everything unless something stops it."
Key rules
- Enforce at the database, not the application. Application bugs can't bypass database-level policies.
- Fail closed. If session variables aren't set, the policy returns zero rows, not all rows.
- Test isolation in CI. Create two tenants, insert data for both, verify tenant A cannot query tenant B's data.
- Use CASE statements in policies to safely handle empty session variables without UUID casting errors.
Deep dive: Lesson 7: Security by Architecture
Quality Gates
Three automated layers that catch 87% of bugs
Fixing a bug at commit time takes 2 minutes. Fixing the same bug in production takes 40+ hours. That's a 1,200x cost difference. Quality gates are automated checkpoints that catch problems as early as possible.
Pre-commit
Formatting, linting, secret detection. Runs on every save.
Pre-push
Type checking, unit tests, security-focused lint. Runs before code reaches the remote.
CI/CD
Full test suite, coverage enforcement (80%+), dependency audit. Runs on every pull request.
Speed budgets are non-negotiable
The moment a quality check is slow, developers bypass it. Pre-commit hooks that take 30 seconds? Developers add --no-verify to muscle memory. Strict speed budgets keep the system honest.
Key rules
- Each layer has a speed budget. Pre-commit: under 5s. Pre-push: under 60s. CI: under 5 minutes.
- Fail fast. Stop at the first error. Don't run slow checks when fast checks already failed.
- Secret detection is non-optional. Catching a leaked API key before commit costs 0 effort. Catching it after push costs a rotation and an audit.
Deep dive: Lesson 8: Quality Gates · Get the configs: Pattern Composer
Type Safety Pipeline
One schema, zero drift, zero runtime type errors
Your backend defines an API. Your frontend consumes it. Between them is a gap where types can drift. You rename a field in Python, but the TypeScript client still uses the old name. It compiles fine. It deploys. Then it breaks at 2 AM.
A type safety pipeline eliminates this gap. One schema definition, automatically propagated to all consumers.
The pipeline
Change a field in the backend schema. Run one command. Frontend types update automatically. TypeScript compiler catches every broken reference at build time, not runtime.
Key rules
- Never hand-write API client types. Generate them from the OpenAPI spec. Manual type definitions drift within weeks.
- Ban
any. Enablestrict: truein tsconfig. Everyanyis a hole in your type safety net. - Run the pipeline in CI. If generated types differ from committed types, the build fails. No one ships stale types.
Deep dive: Lesson 4: Type Safety Pipeline
How these patterns connect
Domain boundaries give AI focused context (1,000 lines instead of 16,000).
Events keep domains independent. No circular imports, no 8-dependency constructors.
Type safety ensures changes propagate without drift, from backend to frontend.
RLS enforces security at the foundation so you can move fast without fear.
Quality gates catch 87% of bugs automatically before any human reviews code.
A monolith-first approach keeps operational complexity low while you build.
Each pattern reinforces the others. The more you adopt, the faster and safer your development becomes.
Frequently asked questions
What is Domain-Driven Design (DDD) in simple terms?
Do I need microservices to use Domain-Driven Design?
What is event-driven architecture and when should I use it?
Do I need Kafka or RabbitMQ for event-driven architecture?
What is Row-Level Security (RLS) and why does it matter?
What is the monolith-first approach?
What are quality gates and how do they catch bugs automatically?
How do these patterns work together?
Ready to apply these patterns?
Start the free course