- Golang
- SQL
ORM1 Development
Core Ideas and Design Philosophy
In 2025, much of my development workflow has become a collaboration. I, as the human developer, work alongside AI coding agents to build software. This new partnership has profoundly shaped how I think about tools, code, and communication. I’ve found that Domain-Driven Design (DDD) concepts—terms like “Aggregate,” “Entity,” and “Value Object”—have become a critical, high-level vocabulary that both I and my AI partners can understand with precision.
When I can say, “They are one Aggregate,” I expect the agent to understand the full implication: loading the root, its children, and managing its lifecycle. The problem is that the Go ecosystem, for all its strengths in concurrency and performance, lacks persistence tools explicitly built for this collaborative, DDD-centric workflow.
This isn’t my first time facing this problem. I previously built a minimal, 1000-line ORM in Python (orm1-py) which is now used in several professional projects. That experience proved to me that the core of a powerful ORM could be simple, focused, and un-opinionated about the surrounding framework. orm1 for Go is the evolution of that philosophy, designed for the unique challenges of Go and this new practice of AI-assisted development.
This post explains the design principles that shape orm1.
1. Aggregates are First-Class Citizens
The first and most critical challenge in persisting a DDD-based model is handling Aggregates. The most common pitfall is to load an aggregate using a series of JOIN statements.
This approach fails immediately in any real-world scenario. Consider an aggregate like Product, which has ProductOption, ProductImage, and ProductTag children. A query that joins all four tables results in a Cartesian product. If a product has 5 options, 3 images, and 10 tags, you will receive 5 × 3 × 10 = 150 rows. The network bandwidth, memory, and parsing overhead to “deduplicate” this data explodes exponentially. This forces developers to abandon correct aggregate design almost immediately.
type Product struct {
ID int64
Name string
Options []*ProductOption // 5 options
Images []*ProductImage // 3 images
Tags []*ProductTag // 10 tags
}
Here’s what actually comes back from the database:
product_id | product_name | option_id | option_name | image_id | image_url | tag_id | tag_name
-----------|--------------|-----------|-------------|----------|------------|--------|----------
123 | Widget | 1 | Small | 1 | img1.jpg | 1 | New
123 | Widget | 1 | Small | 1 | img1.jpg | 2 | Sale
123 | Widget | 1 | Small | 1 | img1.jpg | 3 | Featured
...
123 | Widget | 5 | XXL | 3 | img3.jpg | 10 | Popular
For a single product, JOINs return 150 rows of duplicate data. For 100 products: 15,000 rows.
orm1 rejects this approach entirely. It loads aggregates using Parental Key Queries. The root entity is loaded first. Then, each child collection is loaded in a separate, efficient query using WHERE parental_key IN (...). This results in a predictable number of queries and a data size that scales linearly, not exponentially.
-- Load parent
SELECT * FROM products WHERE id = 123; -- 1 row
-- Load children via parental keys
SELECT * FROM product_options WHERE product_id = 123; -- 5 rows
SELECT * FROM product_images WHERE product_id = 123; -- 3 rows
SELECT * FROM product_tags WHERE product_id = 123; -- 10 rows
Total: 19 rows instead of 150. For 100 products: ~1,800 rows instead of 15,000.
Some might ask, “Why not use lazy loading?” Lazy loading introduces implicit, “magical” behavior. It becomes difficult to reason about performance because you can’t tell by reading the code when a database query will occur. I believe an aggregate, when loaded, should be “complete.” The persistence layer should guarantee that if you have an aggregate root, you also have all its children. Optimization can come later—for example, by defining a separate, read-only model for specific views—but correctness and predictability must come first.
2. Transparency Over Magic
The ORM world is full of complex patterns that, while interesting, often add implicit complexity that makes code harder to reason about. I chose to prioritize transparency.
The Unit of Work (UoW) pattern is a common example. It promises to track all changes and flush them to the database in a single, topologically sorted operation. In practice, this makes database queries hard to predict. It’s often unclear when a flush() will happen or what SQL it will generate.
// Hypothetical UoW pattern (not orm1)
session := factory.CreateSession()
user := session.Get(ctx, User{}, 123)
user.Email = "new@example.com" // Tracked automatically
order := session.Get(ctx, Order{}, 456)
order.Status = "shipped" // Also tracked
session.Flush() // ??? What SQL runs here? In what order?
Furthermore, it’s not clear if a true UoW is even possible in Go without forcing entities to inherit from a base class or use code generation, which I view as a severe constraint on a “Plain Old Go Struct” design. I decided that an explicit session.Save(myAggregate) call is clearer, more predictable, and easier for the developer to reason about.
// orm1's explicit approach
var user *User
session.Get(ctx, &user, orm1.NewKey(123))
user.Email = "new@example.com"
session.Save(ctx, user) // You know exactly when this queries the DB
Similarly, the Identity Map pattern can create ambiguity. It’s a useful pattern, but it forces the developer to constantly ask, “Is this object I’m holding a fresh copy from the database, or is it a cached-in-memory instance?” If you can’t tell by reading the code, it’s a problem. orm1’s session does not need to hide the database to this degree. Instead of a full-blown identity map, it tracks a simpler concept: persistence state. The session only needs to know, “Does this entity exist in the database (so I UPDATE) or is it new (so I INSERT)?” This provides the necessary functionality without the ambiguity.
The result is predictable behavior. You know when database queries happen, and you know what state your entities are in. There are no surprises during a transaction commit.
3. Pagination Done Right
In my projects, I rely heavily on the Relay specification for GraphQL. I’ve found its cursor-based connection specification to be excellent, even when used outside of GraphQL. It correctly handles complex edge cases, bi-directional pagination, and ordering by nullable columns.
However, it is notoriously difficult to implement correctly every single time. This is a perfect example of what an ORM should provide. It’s a complex, repetitive, high-value problem. orm1 provides a fast, spec-compliant cursor-based pagination feature that correctly handles NULLS FIRST and NULLS LAST ordering. This feature alone saves an enormous amount of code and ensures correctness across all projects that use it.
query := orm1.NewEntityQuery[User](session, "u")
page, err := query.
OrderBy(query.Desc("u.created_at")).
Paginate(ctx, afterCursor, &first, nil, nil)
orm1 generates complex cursor predicates with proper NULL handling. For example, when paginating forward after a cursor with created_at=1609545600, id=2:
-- First query: fetch cursor row values
SELECT u.created_at, u.id FROM users AS u WHERE (u.id = $1)
-- Second query: fetch next page with cursor predicate
SELECT u.id FROM users AS u
WHERE (
(u.created_at > $1 OR u.created_at IS NULL) OR
(u.created_at = $2 AND (u.id > $3 OR u.id IS NULL))
)
ORDER BY u.created_at ASC NULLS LAST, u.id ASC NULLS LAST
LIMIT $4
This correctly handles:
- Multi-column ordering with tie-breaking on primary key
- NULL values with NULLS FIRST/NULLS LAST semantics
- Forward and backward navigation
- Consistent results even when data changes between pages
4. A Minimal and Focused Database Abstraction
A common ORM anti-pattern is attempting to abstract away the database entirely. This includes abstracting SQL functions like COUNT() or SUBSTRING(). These abstractions are fragile because the differences between database vendors are profound and complex. Even a simple COUNT has numerous vendor-specific behaviors.
orm1 does not try to hide your database. Instead, it abstracts only what is necessary for its two core functions:
- Backend API: A clear contract that defines what the ORM needs from a database driver to perform its persistence logic.
type Backend interface {
// Entity CRUD operations
Select(ctx context.Context, op SelectOp) (Rows, error)
Insert(ctx context.Context, op InsertOp) (Rows, error)
Update(ctx context.Context, op UpdateOp) (Rows, error)
Delete(ctx context.Context, op DeleteOp) (int64, error)
// Complex query operations (uses SQL AST)
FetchQuery(ctx context.Context, stmt sql.SQLQuery) (Rows, error)
CountQuery(ctx context.Context, stmt sql.SQLQuery) (int64, error)
// Transaction control
Begin(ctx context.Context) error
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
- SQL API: A set of structured query-building blocks that allow for database-specific expressions where needed.
// Type-safe query building with EntityQuery
query := orm1.NewEntityQuery[User](session, "u")
query.Where("u.age > ?", 18).
Where("u.status = ?", "active").
OrderBy(query.Desc("u.created_at"))
// Vendor-specific expressions are fine
query.Where("to_tsvector('english', u.bio) @@ to_tsquery(?)", searchTerm)
My viewpoint is that vendor-specific expressions are not a failure of the abstraction; they are an expected and necessary part of it. The ORM’s job is to define its minimal persistence scope, support multiple engines within that scope, and embrace database-specific features outside of it.
5. Separation of Concerns: Migration Tools
orm1 intentionally includes no migration tools. Database schema management often requires vendor-specific DDL for features like full-text indexes, partitioning, or custom types. Any ORM-based migration tool will hit these limitations quickly.
The Go ecosystem has excellent, standalone tools for this purpose, such as atlas, golang-migrate, and sqldef. I believe in a separation of concerns: let the ORM focus on mapping application code to table data, and let specialized tools manage schema evolution.
Performance
This clear separation of concerns and transparent design makes performance optimization tractable. When there are no hidden behaviors, bottlenecks become obvious.
I built a comprehensive benchmark suite comparing orm1 against major Go libraries like GORM, Ent, Bun, Raw SQL (pgx), and sqlx. The results validate my approach: orm1 provides competitive performance stemming from its simplicity.
Key Takeaways
Building orm1 taught me that simplicity and performance are not at odds - they’re complementary. By:
- Clearly defining the ORM’s responsibilities
- Embracing transparency over magic
- Backing design decisions with benchmarks
- Optimizing the narrow scope I chose
I created an ORM that’s both faster than established alternatives and easier to reason about.
In the age of AI coding agents, tools with clear conceptual models and explicit behavior will still matter. Not because they’re simpler to build, but because they’re simpler to understand.