Architecture Patterns
Guide to UI patterns, application layering, folder grouping, and monorepo architectures for TypeScript and Python.
Software structure involves three distinct decisions that are frequently conflated: how code inside a single request is organized, how an application is layered, and how files are grouped on disk. When treated as separate concerns, these patterns combine into a clean system rather than competing with one another.
Three separate architectural questions
| Axis | Question it answers | Examples | Scope |
|---|---|---|---|
| UI and request patterns | How is code for one screen or endpoint divided? | MVC, MVT, MVP, MVVM, Flux, Zustand | Request / Component |
| Application architecture | How is an app layered, and what can depend on what? | Layered, Hexagonal, Onion, Clean, DDD, Vertical Slice | Single Application |
| Folder grouping | Where do files live on disk? | Layer-based, feature-based, domain-based, modular | File System |
| System architecture | How many deployables exist and how do they communicate? | Monolith, modular monolith, microservices, serverless | Deployment Unit |
The governing rule across all scales: organize by domain or feature first, by technical layer second, and keep dependencies pointing in a single direction.
UI and request-level patterns
These patterns partition the logic for a single screen or API endpoint into data, display, and coordination. They differ primarily in which component manages coordination.
| Pattern | Components | Core mechanism | Common contexts |
|---|---|---|---|
| MVC | Model, View, Controller | Controller receives input, updates Model, selects View | Rails, Spring MVC, Laravel; Express and FastAPI handlers |
| MVT | Model, View, Template | Django variant: View acts as Controller, Template acts as View | Django |
| MVP | Model, View, Presenter | Passive View with all UI logic placed in Presenter | Android and desktop desktop clients |
| MVVM | Model, View, ViewModel | View binds bidirectionally or reactively to ViewModel state | Angular, SwiftUI, Android Jetpack |
| Flux / Zustand | Store, Actions, View | Unidirectional flow: actions update store, UI reads store | Modern React applications |
Key distinctions
- MVC in practice: The Model holds data and validation logic. The View renders the visual output. The Controller coordinates input and model updates. In a JSON API without HTML rendering, the View simplifies to response serialization.
- Scope limitation: These patterns organize code inside an endpoint handler or UI component. They do not dictate directory layouts.
- Common pitfall: Creating top-level folders named
models/,views/, andcontrollers/is technical-layer grouping. It scatters a single feature across distant directories and scales poorly as the application grows.
Application architecture and layering
Application architecture sets boundaries between business rules, frameworks, and storage drivers. The goal is keeping business logic decoupled from infrastructure details.
| Architecture | Core concept | Practical application |
|---|---|---|
| Layered (N-tier) | Presentation, business logic, data access in downward dependency order | Controller calls service; service calls repository |
| Hexagonal (Ports & Adapters) | Business logic is central; talks to outside world via interfaces (ports) implemented by adapters | Simplifies testing and infrastructure swaps |
| Onion | Concentric rings with dependencies pointing inward | Conceptually identical to Hexagonal |
| Clean Architecture | Explicit rings: entities, use cases, interface adapters, frameworks | Inward dependency rule with formal terminology |
| DDD (Domain-Driven Design) | Models complex business domains using bounded contexts and ubiquitous language | Strategic design philosophy rather than folder structure |
| Vertical Slice | Features contain dedicated handlers, validation, and storage logic | Theoretical basis for feature-based directories |
Layered versus Hexagonal in TypeScript
In a standard layered application, the service imports concrete repository classes directly:
import { BillingRepository } from './billing.repository';
export class BillingService {
constructor(private repo = new BillingRepository()) {}
charge(userId: string, cents: number) {
return this.repo.createInvoice(userId, cents);
}
}In Hexagonal architecture, the service depends on an interface that it owns, while the adapter implements that interface:
export interface InvoiceStore {
createInvoice(userId: string, cents: number): Promise<Invoice>;
}import type { InvoiceStore } from './billing.ports';
export class BillingService {
constructor(private store: InvoiceStore) {}
charge(userId: string, cents: number) {
return this.store.createInvoice(userId, cents);
}
}import type { InvoiceStore } from './billing.ports';
import { prisma } from '@/infra/db';
export class PrismaInvoiceStore implements InvoiceStore {
async createInvoice(userId: string, cents: number): Promise<Invoice> {
return prisma.invoice.create({ data: { userId, amountCents: cents } });
}
}This decoupling allows injecting in-memory stubs during testing without spinning up database containers. In Python, ports are implemented via typing.Protocol or abstract base classes.
Start with standard layered architecture for straightforward features. Apply Hexagonal boundary interfaces to critical business logic and external dependencies like payment gateways, email providers, and LLM APIs.
Folder grouping styles
Folder structure dictates where files reside on disk. The recommended approach for medium to large applications is domain or feature grouping with enforced module boundaries and layered code inside each module.
1. Layer-based ("Package by Layer")
Files are segregated by technical role:
- Pros: Easy to grasp for small projects and tutorials.
- Cons: Every feature is fragmented across multiple directories. Modifying one flow requires navigating multiple folders.
- Appropriate for: Throwaway scripts, prototypes, or services with fewer than 5 entities.
2. Feature-based ("Vertical Slices")
Files are organized by product capabilities:
- Pros: Changes remain localized. Adding or removing a feature touches one directory.
- Cons: Requires explicit lint rules to prevent features from importing each other's private internals.
- Appropriate for: Medium to large web applications and frontends.
3. Domain-based (Bounded Contexts)
Structures files around business domains rather than UI screens:
- Pros: Aligns codebase with business entities. Allows domain models (e.g.
Customerin billing vs.Customerin support) to evolve independently. - Appropriate for: Complex backend services and multi-team environments.
4. Modular (Enforced Public APIs)
Encapsulates feature internals behind a minimal public entry point:
Style comparison
| Style | Grouping axis | Target scale | Primary risk |
|---|---|---|---|
| Layer-based | Technical function | Small | Scattered features, cluttered directories |
| Feature-based | User-facing feature | Medium to large | Uncontrolled cross-feature imports |
| Domain-based | Business domain | Large / Enterprise | Over-engineering simple services |
| Modular | Bounded encapsulation | Medium to Enterprise | Boundary linting configuration overhead |
| Atomic Design | UI component size | Design systems only | Subjective classification debates |
System architecture: deployable units
System architecture defines deployment boundaries and inter-process communication.
| Style | Characteristics | Primary benefit | Main tradeoff |
|---|---|---|---|
| Monolith | Single deployable binary without internal walls | Simple deployment and operations | Code entanglement as team grows |
| Modular Monolith | Single deployable with strict internal module boundaries | Operational simplicity with clean extraction seams | Requires linting discipline in CI |
| Microservices | Multiple independently deployed services with private data | Autonomous scaling and releases | Network failures, distributed transactions, observability overhead |
| Serverless | Managed ephemeral functions billed per call | Zero idle server cost, auto-scaling | Cold start latency, connection limits |
| Event-driven | Asynchronous message queues and event brokers | Decoupled consumers, traffic buffering | Complex tracing and eventual consistency |
| CQRS / Event Sourcing | Segregated read/write models with immutable append logs | Complete audit history, optimized read projections | Substantial operational and modeling complexity |
Why start with a modular monolith?
Microservices address organizational bottlenecks and massive scale, but introduce network latency, distributed state, and deployment overhead immediately.
A modular monolith delivers strict domain boundaries within a single process. If a specific module outgrows the monolith, extracting it requires only swapping in-process method calls for HTTP or queue interfaces.
Extract a module into a standalone service only when:
- It requires independent horizontal scaling or hardware profiles (e.g. GPU compute).
- It runs on a different runtime (e.g. Python for ML alongside a Node.js API).
- A dedicated team requires an isolated release pipeline.
- It requires specific security or compliance isolation.
Pattern stacking: how they combine
Architectural patterns solve problems at different layers and stack together seamlessly:
- System level: A modular monolith defines deployment topology.
- Directory level: Feature-based slices organize code on disk.
- Application level: Controller $\rightarrow$ Service $\rightarrow$ Repository layers govern runtime dependencies.
- State level: Unidirectional stores manage client-side state.
Monorepo structure: TypeScript and Python
A monorepo structure housing Next.js, a TypeScript API, a Python service, and shared packages:
Use pnpm and Turborepo for TypeScript workspace tooling. Keep Python dependencies isolated using uv and its own pyproject.toml, executing Python commands through Turborepo task runners without forcing Python into Node package managers.
Frontend structure (Next.js App Router)
Next.js requires file-based routing inside app/. Keep route handlers thin and colocate business logic within a sibling features/ directory:
Module boundary rules
shared/never imports fromfeatures/.features/*may import fromshared/*andconfig/*.- Features do not import internals of other features; they interact only via the public
index.tsentry point or compose at theapp/level. - Route files (
page.tsx) only fetch data and compose feature components. - Server Components remain the default;
'use client'is restricted to interactive leaf components. - Server Actions reside within the feature that owns the mutation.
Backend structures
Both backends share identical organizational principles: domain modules containing a routing controller, business service, and data repository.
TypeScript backend (modular monolith)
Python backend (FastAPI)
Contract-driven integration between TypeScript and Python
Shared API contracts serve as the single source of truth between TypeScript and Python services:
Integration workflow
- FastAPI generates an OpenAPI JSON specification from Pydantic schemas on build.
- The spec is exported into
packages/contracts/. - An automated script generates typed TypeScript API clients from the OpenAPI specification.
- Both
webandapiconsume the generated SDK rather than manualfetchcalls. - A CI validation step fails the build if the committed TypeScript client does not match the latest Python OpenAPI definition.
Rules for maintaining codebase health
Directory structures degrade unless enforced by automated tooling:
- Module first, layer second: Group files by domain first; place controllers, services, and repositories inside each module.
- Access via public surfaces: Enter modules exclusively through
index.ts(TypeScript) or__init__.py(Python). - Unidirectional dependencies:
appdepends onfeatures, which depends onshared. Across the repository, applications depend onpackages/, never the reverse. - Promote on second use: Move code to
shared/only when two or more distinct features require it, or when it contains zero business logic (e.g. utility debounce, currency formatter). - Enforce boundaries via linting: Use
eslint-plugin-boundariesfor TypeScript andimport-linterfor Python in CI pipelines. - Document decisions via ADRs: Maintain one-page Architecture Decision Records in
docs/adr/. - Avoid preemptive symmetry: Small modules with two files should not have empty placeholder directories.
Architecture Decision Record (ADR) template
# ADR 0003: Use a modular monolith for the API
Status: Accepted
Context: Small team, unified database, no requirement for independent scaling.
Decision: Single deployable with domain module boundaries enforced in CI.
Consequences: Operational simplicity; extraction into independent services remains viable later.Production conventions and pitfalls
Implementation guidelines
- Environment variables: Centralize configuration access in a typed
config/env.ts(using Zod) and Pythonpydantic-settings. Do not reference rawprocess.envin application logic. - DTOs vs. database models: Never return raw database records directly from API endpoints. Map entities to explicit response schemas within the service layer.
- Dependency injection: Pass repository instances and HTTP clients into services via constructors or FastAPI
Dependsto enable unit test mocking. - Colocated tests: Place unit tests adjacent to the implementation file (
billing.service.test.ts), reserving top-leveltests/directories for integration and end-to-end suites.
Common antipatterns
- Turning
shared/into a generic dumping ground for loosely organized helpers. - Adding
index.tsbarrel files to every nested subdirectory, creating circular reference cycles. - Leaking database models across service boundaries instead of versioned contracts.
- Scattering business rules inside UI route files or HTTP controllers.