← Blog
·12 blog.minutes

Database Design Patterns for Modern Applications

From choosing between relational and NoSQL to managing migrations in CI/CD, these are the database design patterns every developer needs to know.

Database design is one of those rare skills that only becomes more important as your application grows. A well-designed schema at the start of a project can support years of feature development without major rewrites. A poorly designed one will make every new feature painful, every query slower, and every deployment riskier. The decisions you make about your data layer echo through every layer of your application, and changing them later is expensive.

This article covers the patterns that matter for modern applications. These are not academic exercises — they are practical strategies that teams use in production to keep their databases fast, maintainable, and safe to change. We will cover storage engine choices, schema design principles, query optimization, architectural patterns like CQRS and event sourcing, and the operational practices that keep databases running smoothly as your team and your data grow.

Choosing a Database: Relational vs. NoSQL

The first and most consequential decision you will make is which category of database to use. The good news is that the old tribal wars are mostly over. Few serious teams are purely relational or purely NoSQL anymore. The pragmatic approach is to pick the right tool for each workload, sometimes running multiple storage engines side by side.

Relational databases like PostgreSQL and SQLite excel when your data has clear relationships, referential integrity matters, and you need to query across entities in flexible ways. If you are building a billing system, an inventory management tool, or any application where a transaction either completes fully or not at all, you want ACID guarantees. Relational databases give you those guarantees with decades of battle testing.

Document databases like MongoDB are a better fit when your data is hierarchical, your access patterns are known in advance, and you are willing to trade consistency guarantees for write throughput or schema flexibility. They shine in content management systems, event logging pipelines, and applications where the shape of data changes frequently.

Here is a practical decision framework for choosing your primary database:

  • Use PostgreSQL by default. It handles 95 % of use cases well, supports JSON columns for document-style data, has excellent indexing, and a mature ecosystem. Start here and only deviate when you have a specific reason.
  • Reach for SQLite when your application runs on-device, in a browser via WASM, or as an embedded database for single-server tools. It is zero-configuration, incredibly fast for reads, and surprisingly capable with recent extensions.
  • Consider MongoDB or Firestore when you have deeply nested documents that you always read and write as a unit, and your consistency requirements are loose enough that you can tolerate eventually consistent reads.
  • Avoid the multi-database trap in production. Running two databases doubles your operational complexity. Only add a second storage engine when you have measured that your primary database cannot handle the workload.

The most common regret I see in production codebases is choosing a NoSQL database for relational data. If your entities reference each other and you need to join them, you want a relational database. The impedance mismatch between application objects and relational tables is real, but it is far smaller than the impedance mismatch between an inherently relational model and a document store that was never designed for it.

Normalization, Denormalization, and the Real Middle Ground

Every developer learns normalization in school. First normal form, second normal form, third normal form — the progression promises a clean, non-redundant schema with no update anomalies. Production reality is more nuanced. Fully normalized schemas often produce queries that join ten tables to render a single page, which is slow and complex. Completely denormalized schemas make writes fast and reads trivial, but they introduce consistency problems and make every write more error-prone.

The practical middle ground is to normalize for integrity and denormalize for performance, but do it deliberately and document your reasoning. Start with a normalized schema that captures the true relationships in your data. Then, as you measure real query patterns, add denormalized fields or summary tables where the performance benefit justifies the added complexity.

-- Start normalized
CREATE TABLE orders (
  id        UUID PRIMARY KEY,
  user_id   UUID NOT NULL REFERENCES users(id),
  status    TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id         UUID PRIMARY KEY,
  order_id   UUID NOT NULL REFERENCES orders(id),
  product_id UUID NOT NULL REFERENCES products(id),
  quantity   INT NOT NULL,
  unit_price NUMERIC(10,2) NOT NULL
);

-- Denormalize only when measured: add total to orders
ALTER TABLE orders ADD COLUMN total NUMERIC(10,2);

-- Keep it consistent with a trigger or application-level logic
CREATE OR REPLACE FUNCTION update_order_total()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE orders SET total = (
    SELECT SUM(quantity * unit_price)
    FROM order_items WHERE order_id = NEW.order_id
  ) WHERE id = NEW.order_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_total_trigger
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_total();

The rule of thumb is simple: never denormalize until you have measured a query that matters. Premature denormalization introduces all the complexity of redundant data without the evidence that it solves a real problem. When you do denormalize, document the decision, add tests that verify consistency, and build a reconciliation job that can detect and fix drift. Redundant data always drifts over time. Planning for drift detection is not pessimism — it is engineering maturity.

Indexing Strategies That Actually Work

Indexes are the most impactful performance optimization available to any database user. A single well-placed index can turn a sequential scan of millions of rows into a handful of page reads. But indexes are not free. Every index adds overhead on writes, consumes storage, and can confuse the query planner if there are too many candidates for a given query.

The strategy that consistently produces good results in production follows three principles. First, index your foreign keys. Every column that references another table should be indexed by default. Join performance depends on index lookups on both sides of the join, and forgetting to index foreign keys is the most common performance mistake in relational databases.

Second, index your query patterns, not your columns. Look at the WHERE clauses and ORDER BY clauses in your slowest queries and create composite indexes that match those patterns exactly. A composite index on (status, created_at) is useless for a query filtering only by created_at, but a composite index on (created_at, status) can serve both queries if created_at is selective enough. The order of columns in a composite index matters enormously.

-- Instead of separate indexes
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created ON orders(created_at);

-- Create composite indexes that match real query patterns
-- Query: SELECT * FROM orders WHERE status = 'active' ORDER BY created_at DESC;
CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC);

-- Query: SELECT * FROM orders WHERE user_id = $1 AND status = 'active';
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

Third, measure before and after. PostgreSQL's pg_stat_user_indexes view shows you which indexes are actually used and which are dead weight. Run a workload, check the index usage statistics, and drop indexes that are never scanned. Unused indexes are not harmless — they slow down every write operation and consume cache memory that could hold useful data pages.

Partial indexes are an underused tool. If you frequently query only a subset of rows — active orders, unprocessed events, non-deleted users — create a partial index that covers only those rows. It will be a fraction of the size of a full index and significantly faster to scan.

-- Partial index: only index active orders
CREATE INDEX idx_orders_active ON orders(created_at DESC)
WHERE status = 'active';

-- This index is tiny compared to a full index and serves the query perfectly

The Repository Pattern and Data Access Abstraction

The repository pattern mediates between your domain logic and your data access code. It defines a collection-like interface for retrieving and persisting aggregates, hiding the details of the underlying storage mechanism. In practice, this means your application code calls methods like userRepository.findById(id) or orderRepository.save(order) without knowing whether the data comes from PostgreSQL, a caching layer, or an external API.

The value of this pattern becomes clear when you need to change your database or introduce a caching layer. A team that has queries scattered across controllers, services, and utility functions faces a rewrite of hundreds of files when they switch from MongoDB to PostgreSQL. A team using repositories changes a handful of implementation files and the interface stays the same.

However, the repository pattern has a well-known tension with relational databases. If your repository interface is too generic — findAll, findById, save, delete — it cannot express the rich query capabilities that relational databases offer. Teams often end up adding specialized query methods to repositories anyway, which gradually turns the abstraction into a leaky one. The solution is to accept that repositories for relational databases will have more methods than repositories for key-value stores. A user repository with findActiveByRole, searchByName, and countByStatus methods is not a failure of abstraction — it is honest about the capabilities of the underlying engine.

A repository that hides query capabilities is not an abstraction — it is a handicap. The goal is to isolate your domain from storage details, not to reduce every database to the lowest common denominator.

CQRS and Event Sourcing: When to Reach for Advanced Patterns

Command Query Responsibility Segregation separates the data mutation path from the data reading path. In its simplest form, a CQRS architecture uses the same database but different models for writes and reads. The write model enforces invariants and produces events. The read model consumes those events and builds denormalized views optimized for specific queries. This separation allows each side to be scaled and optimized independently.

Event sourcing goes a step further. Instead of storing the current state of an entity, you store every event that changed it. The current state is derived by replaying those events. This gives you a complete audit trail, the ability to reconstruct state at any point in time, and a natural source of events for downstream consumers. The trade-off is significant operational complexity — event store infrastructure, projection management, eventual consistency between write and read models, and the cognitive overhead of thinking in events rather than state.

Here is the pattern's honest assessment: most applications do not need CQRS or event sourcing. They add complexity that is only justified when you have specific requirements that simpler architectures cannot meet. Consider CQRS when your read and write workloads have fundamentally different characteristics — high write throughput with complex read projections, or different consistency requirements per operation. Consider event sourcing when you need an immutable audit trail by law or by product requirement, or when every state change must be reconstructible and analyzable.

If you do adopt these patterns, start with CQRS alone and only add event sourcing if the audit trail requirement is explicit. Implementing CQRS by maintaining separate read models in PostgreSQL is manageable. Adding an event store on top is a significant jump in complexity that should be a deliberate, well-funded decision.

Migrations, Connection Pooling, and Operational Sanity

The operational side of database design is where good patterns separate applications that evolve smoothly from applications that need a production incident to change a column type. Three practices consistently distinguish teams that handle database changes well from those that struggle.

First, every schema change must be a reversible migration stored in version control. Tools like Flyway, Liquibase, or Alembic apply migrations in order and track which have been applied. The migration file is code — it is reviewed, tested, and deployed through the same pipeline as application code. Each migration should be small and focused. A migration that adds a column, backfills data, and renames a table in one file is risky. Break it into separate steps that can be rolled back independently.

Second, connection pooling is not optional. Opening a database connection is expensive — it involves a TCP handshake, SSL negotiation, and authentication. A connection pool maintains a set of persistent connections that threads borrow and return. The pool size matters: too few connections and requests queue up, too many and the database spends all its time context-switching. A good starting point for PostgreSQL is pool_size = 2 * CPU_cores, then monitor and adjust based on query latency and connection wait times.

Third, integrate migrations into your CI/CD pipeline with care. The safest pattern is to apply migrations before deploying the new application code. This way, the new code sees the schema it expects, and the old code is still compatible with the new schema because backward-compatible changes were applied first. This requires that every migration is backward-compatible with the current code — no dropping columns that the running code still references, no renaming tables without a transition period.

  • Expand: add the new column or table while the old code still runs.
  • Migrate: backfill data and transition writes to the new structure.
  • Contract: remove the old column or table after confirming the old code is no longer running.

This expand-migrate-contract pattern for zero-downtime schema changes works because it never leaves the database in a state that the running code cannot handle. It requires discipline — you need to keep old code paths alive for one deployment cycle — but it eliminates the most common cause of deployment failures related to database changes.

SQL vs. ORM: Finding the Right Balance

The debate between writing raw SQL and using an Object-Relational Mapper is one of the most persistent arguments in software development. Both sides have valid points, and the right answer depends on the context of your project.

ORMs like Prisma, TypeORM, or SQLAlchemy provide automatic mapping between your application objects and database tables, migration management, and query building in your application's language. They eliminate entire categories of boilerplate and make it easy to get started. The cost is that they abstract away SQL, which means when something goes wrong — a slow query, an unexpected join, a lock escalation — you have to understand both the ORM's behavior and the underlying SQL to debug it. ORMs also tend to generate suboptimal queries for complex access patterns, and the N+1 query problem is a rite of passage for every team using an ORM.

Raw SQL gives you complete control over what executes on the database server. You can write exactly the query you need, tuned to your schema and your database engine's capabilities. The cost is that you lose automatic mapping, you have to manage migrations yourself, and your codebase ends up with SQL strings scattered everywhere that are hard to test and harder to refactor.

The pragmatic middle ground is to use an ORM for the 80 % of queries that are straightforward CRUD operations and drop to raw SQL for the 20 % that need performance tuning or complex reporting. Most good ORMs provide a way to execute raw queries and map results back to typed objects. Use the ORM's query builder for simple operations, write raw SQL for complex ones, and test both against your actual database with realistic data volumes.

PostgreSQL is the default choice for most modern applications because it balances capability, reliability, and ecosystem better than any other database. SQLite dominates the embedded and local-first space. MySQL remains common in legacy and WordPress ecosystems. The database you choose matters less than the patterns you apply to use it well — schema design, indexing, migration management, and operational practices determine your success far more than the logo on the tin.

The database you choose matters less than the patterns you apply to use it well. Schema design, indexing, and operational practices determine your success far more than the logo on the tin.

Database design is not a one-time activity. It is a continuous practice of measuring, adjusting, and learning. The patterns in this article will serve you well as a starting point, but the real expertise comes from observing how your database behaves under real workloads and responding with targeted, deliberate changes. Start simple. Measure everything. And never be afraid to reach for a migration file.