DEV Community

Cover image for From Framework Lock-in to Building Our Own ORM
Amirsaeed Sadeghi
Amirsaeed Sadeghi

Posted on

From Framework Lock-in to Building Our Own ORM

Why we decided to build a custom ORM in a real-world project

The Problem

In one of our enterprise projects, the core requirement was clear: the
Core had to be fully independent from any framework
--- so that future
migrations (to another framework or even another programming language)
would not require rewriting the business logic.
That requirement forced us to build the Core from scratch, without any
framework dependencies. The biggest challenge? Designing a
framework-agnostic Data Access Layer, which naturally led us to
build our own QueryBuilder/ORM.

Architectural Goals

  • Decouple domain logic from infrastructure and framework layers
  • Enable technology migration without rewriting domain code
  • Achieve full unit-testability without a real database
  • Gain fine-grained control over query generation and execution

Why existing ORMs didn't fit

  • Framework-Independent Data Layer -- Most popular ORMs are deeply tied to the lifecycle and conventions of their host framework.
  • Unit Testing without a real DB -- We needed to test repositories and queries with in-memory fakes.
  • Modular Clauses -- Each clause had to be composable and replaceable without touching the Core.
  • Precise Execution Control -- We required control over query batching, timing, and transaction handling.

When "build" makes more sense than "buy"

  • Domain requirements impose contracts that off-the-shelf ORMs can't model cleanly.
  • Long-term architectural stability and Core independence outweigh early speed.
  • The team has enough technical maturity and resources to maintain a custom data layer.
  • Migration cost and framework lock-in risk are expected to be high across the product's lifespan.

If these conditions aren't met, adopting a mature ORM is wiser.
Reinventing the wheel only makes sense when the wheel you need doesn't
exist.

Design Foundations

  • Fluent Interface -- A clean, chainable API for query construction
  • Clause Strategy + Factory -- Each clause implemented as a strategy; a factory orchestrates their composition
  • Query Context -- Carries state, bindings, and execution metadata
  • Collection Integration -- Returns results as a collection object supporting map, filter, paginate, and metadata
  • Ports & Adapters --
    • Ports: Repository and QueryService as Core contracts
    • Adapters: Database drivers (PDO, MySQL, PostgreSQL) and in-memory adapters for testing
  • Execution Pipeline -- Staged SQL building, parameter binding, secure execution, and mapping to domain models
  • Error Mapping -- Translates low-level DB errors into application-level exceptions

Keys to Testability

  • In-Memory Driver -- Executes clauses in memory for unit tests
  • Contract Tests -- Guarantee adapter compliance with Core interfaces
  • Deterministic Hydration -- Predictable mapping from raw data to domain models
  • Boundary Tests -- Validate that infrastructure concerns never leak into the domain

Performance and Optimization

  • Lazy vs. Eager Execution -- Build queries lazily, execute explicitly at defined boundaries
  • Batching -- Combine operations to reduce round-trips
  • Index-Aware Hints -- Inject DB-specific execution hints without leaking them into Core logic
  • Metrics Hooks -- Measure runtime performance, record counts, and detect N+1 issues

Transactions and Concurrency

  • Unit of Work (optional) -- Track and commit grouped changes atomically
  • Explicit Transactions -- Provide clear APIs for begin/commit/rollback at the adapter layer
  • Idempotency and Retries -- Built-in hooks for retrying critical operations safely

Pitfalls and Anti-patterns

  • Reinventing Active Record -- Mixing domain models with DB operations defeats independence
  • Infrastructure Leakage -- No low-level types or exceptions should cross Core boundaries
  • Low-level APIs inside Core -- The Core should talk through domain contracts, not SQL or clauses
  • Ignoring Migration Paths -- Always design for adapter and database version evolution

Comparison Snapshot

  • Eloquent: Excellent developer experience within Laravel, but tightly coupled to it
  • Doctrine: Highly flexible and decoupled, but heavy and complex
  • Custom ORM (ours): Tailored to exact architectural needs, at the cost of maintenance responsibility

Outcome

  • A framework-agnostic Core, portable across frameworks or even languages
  • Strong testability without relying on a physical database
  • Full control over query generation and execution paths
  • Greater architectural transparency and deeper engineering insight within the team

When not to take this path

  • The project is short-lived or framework lock-in is an acceptable risk
  • The team is small and can't maintain a custom data layer
  • Existing ORMs already meet all functional and architectural requirements

Conclusion

Building a custom ORM is both a technical and economic decision --- not
a vanity project.
If Core independence, offline testability, and precise control over data
access are your first-order priorities and your team can sustain the
maintenance cost, then this path is worth it.
Otherwise, rely on proven ORMs and keep your focus on the domain.

Top comments (0)