Open source · Go module

One interface. Many databases. Zero rewrites.

DALgo is a Database Abstraction Layer for Go. Write your domain logic once against a single, well-tested interface — swap Postgres for Firestore, SQLite for Datastore, an in-memory store for a real one — without touching your business code.

Get started pkg.go.dev $ go get github.com/dal-go/dalgo
01 — Why a DAL

Storage is a detail, not a destiny.

Six reasons developers reach for DALgo on day one.

Storage-agnostic logic

Write business code against dal.DB. Decide later whether it lives in Postgres, Firestore, or a JSON file on disk.

Less code, more readable

One small, opinionated API replaces piles of driver-specific boilerplate. Records, keys, queries, transactions — uniform across every adapter.

Testable by construction

Swap a production driver for the built-in dalgo2memory adapter in tests. Need mocks instead? The generated mocks package ships in the core repo.

Hooks & logging, for free

Because every operation goes through one interface, observability and audit logging are a wrapper — not a refactor.

One end-to-end test suite

Every official adapter is validated against the same end-to-end test suite. Conformance is the contract.

Transactions, the Go way

Read-only and read-write transactions are first-class. Pass a closure, get atomicity — without leaking driver-specific transaction types into your domain.

02 — Adapters

Same interface. Different stores.

Two adapters ship inside the core module; the rest span SQL, NoSQL, file, and Git-versioned storage.

03 — Quick start

Install the core. Pick an adapter. Write a record.

A working example in fewer than 40 lines.

  1. 1

    Install the core module. The built-in dalgo2memory adapter ships with it — no extra dependency to get started.

  2. 2

    Open a database. The returned dal.DB is the only type your app needs to know about.

  3. 3

    Define a record, set its data, then read it back by key.

  4. 4

    Later, swap dalgo2memory for dalgo2sqlite or dalgo2firestore. Your handlers don't change.

main.go Go
// $ go get github.com/dal-go/dalgo

package main

import (
    "context"
    "fmt"

    "github.com/dal-go/dalgo/dal"
    "github.com/dal-go/dalgo/adapters/dalgo2memory"
)

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func main() {
    ctx := context.Background()
    db := dalgo2memory.NewDB()

    key := dal.NewKeyWithID("users", "u1")
    user := &User{Name: "Ada Lovelace", Email: "ada@example.com"}
    if err := db.Set(ctx, dal.NewRecordWithData(key, user)); err != nil {
        panic(err)
    }

    var got User
    record := dal.NewRecordWithData(key, &got)
    if err := db.Get(ctx, record); err != nil {
        panic(err)
    }
    if record.Exists() {
        fmt.Println(got.Email)
    }
}
04 — Typed collections

Less ceremony for everyday CRUD.

A generic, session-less dal.Collection[T] handle returns typed values directly — no key wrangling, no record wrapping, no type assertions. It is additive over the core API and works with every adapter.

collections.go Go
type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// CollectionName (value receiver) names the collection.
func (User) CollectionName() string { return "users" }

// A Collection[T] holds no session — declare it once and reuse it.
var Users = dal.CollectionOf[User]()

func demo(ctx context.Context, db dal.DB) error {
    // Writes take a WriteSession — only reachable inside a transaction.
    if err := db.RunReadwriteTransaction(ctx,
        func(ctx context.Context, tx dal.ReadwriteTransaction) error {
            return Users.Set(ctx, tx, "u1",
                User{Name: "Ada Lovelace", Email: "ada@example.com"})
        }); err != nil {
        return err
    }

    // Reads take a ReadSession (a plain dal.DB works) and return T.
    user, err := Users.Get(ctx, db, "u1")
    if err != nil { // not-found via dal.IsNotFound(err)
        return err
    }
    fmt.Println(user.Email)
    return nil
}

The same handle also offers Insert (generated id), InsertWithID, All, First, Count, Exists, Update, Delete, batch InsertMany, and In(parentKey) for nested collections.

Standard database/sql vs DALgo

The same operations written against the standard library and against DALgo. The DALgo version is backend-agnostic — identical code runs on Firestore, SQL, the filesystem, or the in-memory adapter.

Read one user by id — and return a typed value directly.

database/sql Go
// Standard library: hand-written SQL + Scan.
type User struct {
    ID    int
    Name  string
    Email string
}

func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
    row := db.QueryRowContext(ctx,
        "SELECT id, name, email FROM users WHERE id = ?", id)

    u := &User{}
    err := row.Scan(
        &u.ID,
        &u.Name,
        &u.Email,
    )
    if errors.Is(err, sql.ErrNoRows) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return u, nil
}
DALgo Go
// DALgo: one typed call, any backend.
type User struct {
    ID    int
    Name  string
    Email string
}

func GetUser(ctx context.Context, db dal.DB, id int) (User, error) {
    return dal.CollectionAt[User]("users").Get(ctx, db, id)
}

In one transaction: load the user; if not banned, flag them and add a Ban record.

database/sql Go
// Standard library: manual BEGIN / COMMIT / ROLLBACK.
type User struct {
    ID       int
    Name     string
    Email    string
    IsBanned bool
}

type Ban struct {
    UserID       int
    BanTimestamp time.Time
}

func BanUser(ctx context.Context, db *sql.DB, id int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var u User
    err = tx.QueryRowContext(ctx,
        "SELECT id, name, email, is_banned FROM users WHERE id = ?", id).
        Scan(&u.ID, &u.Name, &u.Email, &u.IsBanned)
    if err != nil {
        return err
    }
    if u.IsBanned {
        return nil
    }

    if _, err = tx.ExecContext(ctx,
        "UPDATE users SET is_banned = TRUE WHERE id = ?", id); err != nil {
        return err
    }
    if _, err = tx.ExecContext(ctx,
        "INSERT INTO bans (user_id, ban_timestamp) VALUES (?, ?)",
        id, time.Now()); err != nil {
        return err
    }
    return tx.Commit()
}
DALgo Go
// DALgo: one closure, the driver runs the transaction.
type User struct {
    ID       int
    Name     string
    Email    string
    IsBanned bool
}

type Ban struct {
    UserID       int
    BanTimestamp time.Time
}

func BanUser(ctx context.Context, db dal.DB, id int) error {
    return db.RunReadwriteTransaction(ctx,
        func(ctx context.Context, tx dal.ReadwriteTransaction) error {
            users := dal.CollectionAt[User]("users")
            user, err := users.Get(ctx, tx, id)
            if err != nil {
                return err
            }
            if user.IsBanned {
                return nil
            }
            if err = users.Update(ctx, tx, id,
                []update.Update{update.ByFieldName("IsBanned", true)}); err != nil {
                return err
            }
            bans := dal.CollectionAt[Ban]("bans")
            _, err = bans.Insert(ctx, tx, Ban{UserID: id, BanTimestamp: time.Now()})
            return err
        })
}
05 — The interface

Small enough to memorize.

Strict enough to keep every adapter honest.

Every DALgo adapter — SQL, NoSQL, embedded, filesystem — implements the same dal.DB contract. Read operations live on ReadSession; mutating operations live on ReadwriteSession. Transactions are closures: pass a function, the driver gives you a session, your code never sees a driver-specific handle.

Drivers that can't implement an operation return dal.ErrNotSupported — explicit failure beats silent nonsense.
dal/db_database.go Go
package dal

type DB interface {
    ID() string
    Adapter() Adapter
    Schema() Schema
    TransactionCoordinator
    ReadSession
    ConcurrencyAware
}

type TransactionCoordinator interface {
    RunReadonlyTransaction(ctx context.Context,
        f ROTxWorker, opts ...TransactionOption) error

    RunReadwriteTransaction(ctx context.Context,
        f RWTxWorker, opts ...TransactionOption) error
}

type ReadSession interface {
    Get(ctx context.Context, record Record) error
    GetMulti(ctx context.Context, records []Record) error
    ExecuteQueryToRecordsReader(ctx context.Context,
        query Query) (RecordsReader, error)
}

type ReadwriteSession interface {
    ReadSession
    Insert(ctx context.Context, r Record, opts ...InsertOption) error
    Set(ctx context.Context, r Record) error
    Update(ctx context.Context, key *Key,
        updates []update.Update, pre ...Precondition) error
    Delete(ctx context.Context, key *Key) error
}
06 — Queries & schema

More than get and set.

A structured query builder, hierarchical collections, and schema-aware key mapping — capability-based across adapters.

DALgo ships a structured query builder for the common database reads: filters, ordering, LIMIT, column projection, GROUP BY / HAVING, aggregates like COUNT(*) and SUM, and inner / left equi-joins. Keys can model nested document paths such as countries/ireland/cities/dublin, and a parent key scopes a query to a nested collection.

Adapter support is capability-based: a backend that can't run a query feature returns dal.ErrNotSupported, so tests share one query shape and skip cleanly. The serialized query format lives in DTQL.
query.go Go
// Query the largest cities, projecting two columns.
cities := dal.NewRootCollectionRef("cities", "")

q := dal.From(cities).NewQuery().
    WhereField("Country", dal.Equal, "IE").
    OrderBy(dal.DescendingField("Population")).
    Limit(10).
    SelectColumns(
        dal.Column{Expression: dal.Field("Name")},
        dal.Column{Expression: dal.Field("Population")},
    )

records, err := dal.ExecuteQueryAndReadAllToRecords(ctx, q, db)

Storage is a detail. Treat it like one.

DALgo is MIT-licensed, production-tested, and waiting for you to break it. Contributions for new adapters and edge cases are welcome.