Introduction
Object-oriented design has a well-established vocabulary for talking about good structure: the SOLID principles, popularized by Robert C. Martin, and the twenty-three patterns catalogued in Design Patterns: Elements of Reusable Object-Oriented Software by Gamma, Helm, Johnson, and Vlissides - commonly known as the Gang of Four (GoF). SQL has no equivalent canon. There is no widely cited "SQL Gang of Four," and most engineers who are fluent in both object-oriented design and relational databases have never been shown an explicit bridge between the two vocabularies, even though the underlying concerns - separation of responsibility, controlled extension, encapsulation of complexity - are strikingly similar in both domains.
This article draws that bridge deliberately. The goal is not to claim that a VIEW literally is the Facade pattern, or that a foreign key constraint is the Liskov Substitution Principle in disguise - those would be overreaching equivalences. The goal is to use analogies you already understand deeply, from years of object-oriented design experience, as scaffolding for reasoning about schema and query design decisions that otherwise feel like arbitrary conventions. If you have internalized why the Single Responsibility Principle keeps classes maintainable, you already have most of the intuition needed to understand why a table trying to do too much becomes a maintenance liability - the analogy just needs to be made explicit.
Why the Comparison Is Useful, Not Just Cute
It would be easy to dismiss this exercise as a cute rhetorical device with no real engineering payoff. But the comparison earns its place for a concrete reason: SOLID and GoF patterns exist to manage the same underlying force that database schemas contend with - coupling and the cost of change. In object-oriented systems, poor coupling shows up as a change to one class rippling unpredictably into others. In relational schemas, poor coupling shows up as a change to one table's shape breaking five queries in three different services, or a migration that cannot be deployed without a coordinated, multi-team release. The forces are the same; only the syntax differs.
There is also a practical, organizational reason this framing helps. Most engineering teams have a shared vocabulary for object-oriented design because it is taught in almost every computer science curriculum and reinforced in code review. That vocabulary rarely gets applied to schema design, which is often treated as a separate, lower-status skill delegated to "whoever needs a table today." When a team can say "this table is violating SRP" or "we're using the equivalent of an EAV anti-pattern here, which is like a Visitor pattern applied without justification," they get access to a shared, already-understood set of intuitions for evaluating schema quality - rather than having to build a new vocabulary specific to databases from scratch.
Finally, the comparison is useful because it exposes where the analogy breaks down, and that breakdown is itself informative. Relational databases operate under constraints that object graphs do not: set-based operations instead of pointer traversal, a query optimizer instead of a JIT compiler, and a strong emphasis on declarative correctness over imperative control flow. Recognizing where the OOP analogy stops working is often the fastest way to understand what is genuinely distinctive about relational thinking, rather than assuming it is just "classes with different syntax."
SOLID Principles, Translated to Schema Design
The Single Responsibility Principle states that a class should have one reason to change. The schema equivalent is a table that models exactly one entity or one clearly bounded concept, rather than a table that has accumulated columns for three unrelated concerns because it was convenient to bolt them on. A classic violation is a users table that started as authentication data and slowly absorbed billing preferences, notification settings, and marketing consent flags. Each of those concerns changes for a different reason and at a different cadence, and lumping them into one table means a schema migration for notification preferences risks locking or rewriting a row that also holds authentication data on the hot login path.
The Open/Closed Principle - open for extension, closed for modification - maps naturally onto the tension between adding new columns and adding new tables when a schema needs to support a new variant of an existing concept. A payments table that started with a credit_card column, then grew a paypal_email column, then a bank_account_number column for every new payment method, is being modified rather than extended; every new payment type requires an ALTER TABLE and a wider table with more nullable columns. The extension-friendly alternative is a payments table with a payment_method_type discriminator and either a polymorphic association pattern or per-type child tables (credit_card_payments, paypal_payments) joined back to the parent - new payment types become new tables joined in, not new columns bolted onto an existing one.
The Liskov Substitution Principle - subtypes must be substitutable for their base type without breaking correctness - has a real analog in how supertype/subtype table hierarchies are modeled. If a vehicles table represents the common shape shared by cars and trucks, then any query written against vehicles should behave correctly regardless of which subtype actually populated a given row. This breaks in practice when a subtype-specific column gets smuggled into the shared table with implicit assumptions - for instance, a cargo_capacity column on the shared vehicles table that is only meaningful for trucks and silently NULL for cars, forcing every query against vehicles to special-case it. The LSP-respecting version keeps cargo_capacity on a trucks child table, so the shared vehicles table remains genuinely substitutable for any query that only cares about the common shape.
The Interface Segregation Principle - clients should not be forced to depend on interfaces they do not use - corresponds to the practice of exposing narrow, purpose-built views or query interfaces instead of forcing every consumer to SELECT * from a wide underlying table. A reporting service that only needs customer_id, order_total, and order_date should not depend on the full orders table, which might also carry payment tokens, internal fraud-scoring fields, and audit columns; a narrow view that exposes only the reporting-relevant columns keeps the reporting service from breaking every time an unrelated column is added to the base table.
Finally, the Dependency Inversion Principle - depend on abstractions, not concretions - is the strongest argument for the repository pattern and for keeping raw SQL out of business logic. Application code that directly embeds table names and column names throughout its business logic is depending on the concrete schema; application code that depends on a repository interface, with the SQL isolated behind it, can have its underlying schema refactored without every call site needing to change.
// Anti-pattern: business logic depends directly on schema shape (DIP violation)
async function getActiveCustomerTotal(customerId: string) {
const rows = await db.query(
`SELECT SUM(total_cents) as total
FROM orders
WHERE customer_id = $1 AND status != 'cancelled'`,
[customerId]
);
return rows[0].total;
}
// Pattern: business logic depends on an abstraction; SQL is isolated behind it
interface OrderRepository {
getActiveCustomerTotal(customerId: string): Promise<number>;
}
class PostgresOrderRepository implements OrderRepository {
async getActiveCustomerTotal(customerId: string): Promise<number> {
const rows = await db.query(
`SELECT SUM(total_cents) as total
FROM orders
WHERE customer_id = $1 AND status != 'cancelled'`,
[customerId]
);
return rows[0].total;
}
}
// Business logic depends only on the interface
async function chargeLoyaltyBonus(repo: OrderRepository, customerId: string) {
const total = await repo.getActiveCustomerTotal(customerId);
return total > 100_00 ? 10_00 : 0;
}
Gang of Four Patterns Mirrored in SQL Constructs
Several GoF patterns have direct structural cousins in common SQL practice, even though the underlying mechanism - a database view versus a class hierarchy - is quite different. The Facade pattern, which provides a simplified interface to a complex subsystem, is mirrored almost exactly by a database VIEW that hides a multi-table join behind a single, simple queryable name. A customer_order_summary view that joins customers, orders, and order_items internally, exposing a flat set of columns, gives consuming code the same benefit a Facade gives calling code: it does not need to know how many tables or joins are behind the scenes, and the underlying join logic can change without breaking every consumer.
-- Facade-equivalent: a view hides the join complexity behind a simple interface
CREATE VIEW customer_order_summary AS
SELECT
c.id AS customer_id,
c.email,
COUNT(o.id) AS total_orders,
COALESCE(SUM(oi.quantity * oi.unit_price_cents), 0) AS lifetime_spend_cents
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status != 'cancelled'
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.id, c.email;
-- Consumers query the facade, not the underlying joins
SELECT customer_id, lifetime_spend_cents
FROM customer_order_summary
WHERE lifetime_spend_cents > 50000;
The Decorator pattern, which attaches additional responsibilities to an object dynamically by wrapping it, has a loose analog in views (or CTEs) built on top of other views, each layer adding a transformation without modifying the layer beneath it. A base view exposing raw order data can be wrapped by a second view that adds a computed is_high_value flag, which can itself be wrapped by a third view adding currency conversion - each layer adds behavior without touching the one underneath, the same way a Decorator wraps a component without altering its source.
The Strategy pattern, which lets an algorithm vary independently of the client that uses it, maps onto the increasingly common practice of parameterizing query behavior at the application layer rather than hardcoding one fixed query shape - for instance, selecting between different sort or filter strategies based on a caller-supplied configuration, while the underlying data-access code remains structurally the same. This is also visible in how query builders (Knex, SQLAlchemy's query API, Prisma's query builder) let application code compose a query strategy dynamically, swapping filter or sort logic without rewriting the whole data-access layer.
The Composite pattern, which lets clients treat individual objects and compositions of objects uniformly, has a genuine structural cousin in the recursive common table expression (CTE), used to query hierarchical data - an org chart, a category tree, a bill of materials - where a "leaf" row and a "branch" row are queried through the same recursive shape.
-- Composite-equivalent: recursive CTE treats leaf and branch category rows uniformly
WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM categories
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, ct.depth + 1
FROM categories c
JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT * FROM category_tree ORDER BY depth, name;
Practical Examples: Applying the Analogy End to End
Consider a subscription billing system that needs to support multiple payment providers, a scenario that combines several of the analogies above into one coherent design decision. Applying the Open/Closed Principle suggests modeling payments with a discriminator column and per-provider child tables rather than a wide table with a column per provider. Applying the Dependency Inversion Principle suggests that the billing service's business logic should depend on a PaymentRepository interface rather than embedding provider-specific SQL directly in the billing workflow. And applying the Facade pattern suggests exposing a payment_summary view that flattens the provider-specific child tables into one queryable shape for reporting, so the reporting team never needs to know which provider processed a given payment.
# Repository abstraction (Dependency Inversion) over a provider-extensible schema (Open/Closed)
class PaymentRepository:
def record_payment(self, customer_id: str, amount_cents: int, provider: str, provider_ref: str):
raise NotImplementedError
class PostgresPaymentRepository(PaymentRepository):
def record_payment(self, customer_id, amount_cents, provider, provider_ref):
payment_id = self.db.execute(
"""
INSERT INTO payments (customer_id, amount_cents, provider, created_at)
VALUES (%s, %s, %s, now())
RETURNING id
""",
(customer_id, amount_cents, provider),
)
# Extension point: new providers add a new child table, not a new column
if provider == "stripe":
self.db.execute(
"INSERT INTO stripe_payments (payment_id, provider_ref) VALUES (%s, %s)",
(payment_id, provider_ref),
)
elif provider == "paypal":
self.db.execute(
"INSERT INTO paypal_payments (payment_id, provider_ref) VALUES (%s, %s)",
(payment_id, provider_ref),
)
return payment_id
This design pays off exactly where you would expect from the object-oriented analogy: adding a third payment provider means adding a new child table and a new branch in the repository's extension point, without modifying the payments table's schema, without touching the payment_summary view's consumers, and without the reporting team needing to learn anything new about the underlying providers. The cost of change is isolated to the layer where the actual change occurred - which is the entire point of both SOLID and GoF in their original object-oriented context.
Trade-offs and Pitfalls of the Analogy
The comparison breaks down in a few important places, and pretending otherwise leads to bad schema decisions. First, relational databases are set-oriented and declarative, while object-oriented design patterns assume an imperative, reference-based object graph. A GoF pattern like Observer, which relies on push-based notification between objects, has no clean SQL analog - the closest relational construct, a database trigger, is generally discouraged in modern practice because it hides business logic outside application code and makes the system's behavior harder to trace, which is close to the opposite of what Observer is meant to achieve in a well-factored object-oriented codebase.
Second, over-applying the extension-friendly instinct from Open/Closed can produce the Entity-Attribute-Value (EAV) anti-pattern, where a schema becomes so generic that almost anything can be modeled without a migration - at the cost of losing type safety, foreign key integrity, and the ability to write efficient queries or the query planner's ability to reason about the data at all. EAV is what happens when the OOP instinct toward extensibility is pushed onto a relational schema without respecting that a database's value comes largely from its constraints, not from being infinitely generic; this exact anti-pattern is discussed explicitly in Bill Karwin's SQL Antipatterns, which is worth reading as a caution against taking any of these analogies too literally.
Finally, chasing structural parallels for their own sake can produce schemas that are "clever" in a way that actively hurts query performance. A schema with five layers of Decorator-style views stacked on top of each other looks elegant on a whiteboard, but each layer of view-on-view composition adds planning overhead, and in some databases can prevent the optimizer from pushing predicates down efficiently through all the layers. The analogy is a reasoning tool, not a design mandate - it should inform judgment, not replace the concrete cost analysis that any schema decision ultimately requires.
Best Practices for Using This Mental Model
Use the SOLID and GoF vocabulary as a diagnostic language in schema review, not as a literal template to force onto every table. When a table is accumulating unrelated columns, naming the smell as an SRP violation gives the team a shared, already-understood reason to split it, rather than relying on a vague feeling that "this table has gotten big." Similarly, when a migration keeps widening a table with new nullable columns for each new variant of a concept, naming it as an Open/Closed violation makes the case for a discriminator-plus-child-table redesign concrete and easy to communicate in a design review, especially to engineers whose primary background is application code rather than database design.
At the same time, resist the temptation to force every SQL construct into a one-to-one correspondence with a specific GoF pattern. The value of the analogy is in the underlying forces it makes visible - coupling, extension cost, encapsulation of complexity - not in claiming that a VIEW and a Facade class are literally the same artifact. When the analogy stops producing insight and starts producing awkward, forced comparisons, that is the signal to fall back on relational-specific reasoning: what does the query planner actually do with this shape, what does the access pattern actually require, and what does the isolation level actually guarantee.
Analogies and Mental Models
A useful compact mental model: think of a table as a class definition, a row as an instance, and a foreign key as a reference - but remember that unlike an object graph, the relational model has no notion of identity beyond the primary key, and no encapsulated behavior traveling with the data. A table cannot have private methods; every "operation" on the data happens externally, in a query, a stored procedure, or application code. This is precisely why the Dependency Inversion analogy matters so much in practice: because the schema itself has no behavior to encapsulate, the discipline of keeping SQL behind a repository interface is doing the job that a well-encapsulated class would normally do for you automatically.
A second useful model is to think of a view as a "read-only subclass" of its underlying tables: it presents a shape derived from its parents, can add computed fields, and can be swapped out without the base tables knowing anything about it - much like how a subclass can override behavior without the base class needing awareness of the override. This framing makes the Facade and Decorator analogies intuitive without needing to memorize a formal definition of either pattern.
The 80/20 Insight
Of all the analogies in this article, two do almost all of the practical work. The first is Single Responsibility applied to tables: most painful schema refactors trace back to a table that grew to serve two or three unrelated concerns, and catching this early - one table, one clear responsibility - prevents a disproportionate share of future migration pain. The second is Dependency Inversion applied to the application-data boundary: isolating SQL behind a repository or data-access layer, rather than scattering raw queries through business logic, is what makes every other improvement in this article - swapping a view, restructuring a table, changing an index - cheap instead of catastrophic. If you adopt only these two habits, you capture most of the value this entire comparison has to offer.
Key Takeaways
- Treat "this table violates SRP" as a legitimate, actionable code review comment, not just an OOP metaphor - it usually signals a table doing two or three unrelated jobs.
- When a schema keeps growing new nullable columns for each new variant of a concept, that is an Open/Closed violation; model the variation as child tables with a discriminator instead.
- Isolate SQL behind a repository interface (Dependency Inversion) so schema changes do not ripple through business logic.
- Use views as Facades for genuinely complex joins, but watch for performance costs when stacking many view layers (the Decorator analogy has real limits).
- Recognize when the OOP analogy breaks down - EAV schemas and database triggers are places where forcing an object-oriented instinct onto a relational model tends to backfire.
Conclusion
SOLID and the Gang of Four were never written with relational databases in mind, but the forces they were designed to manage - uncontrolled coupling, awkward extension, leaked complexity - are exactly the forces that make schemas brittle over time. Mapping these principles onto tables, views, and query design does not give you a new set of rules to follow mechanically; it gives you a vocabulary you likely already have, repurposed to reason about a domain that too often gets treated as an afterthought compared to application code design.
The value of this exercise is diagnostic, not prescriptive. A table that keeps absorbing unrelated columns, a schema that widens instead of extends, business logic tightly coupled to raw SQL - these are the same smells experienced object-oriented engineers already know how to spot, just wearing different syntax. Where the analogy holds, it accelerates good judgment. Where it breaks down - set-based operations, the absence of behavior traveling with data, the cost model of a query optimizer - it tells you something genuinely important about what makes relational thinking its own discipline, distinct from object-oriented design, rather than a dialect of it.
References
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- Martin, R. C. Agile Software Development, Principles, Patterns, and Practices. Prentice Hall. (Source of the SOLID principles.)
- Karwin, B. SQL Antipatterns: Avoiding the Pitfalls of Database Programming. Pragmatic Bookshelf.
- Fowler, M. Patterns of Enterprise Application Architecture. Addison-Wesley.
- Kleppmann, M. Designing Data-Intensive Applications. O'Reilly Media.
- PostgreSQL Global Development Group. PostgreSQL Documentation: Views and Materialized Views. postgresql.org/docs
- PostgreSQL Global Development Group. PostgreSQL Documentation: WITH Queries (Common Table Expressions). postgresql.org/docs