DEV Community

Cover image for πŸ—οΈ Building a Clean Architecture API with Go, Ore, and SQLite
Firas M. Darwish for Lilury

Posted on

πŸ—οΈ Building a Clean Architecture API with Go, Ore, and SQLite

So you've been writing Go for a bit. Your main.go is growing. You've got a database call next to an HTTP handler next to a business rule, and somewhere in the back of your head a little voice keeps whispering "this is going to be a nightmare to test."

That voice is right. And today we're going to silence it. 🀫

We're going to build a Book Library REST API from scratch using Clean Architecture - a layered design that keeps your business logic completely isolated from databases, HTTP frameworks, and anything else that changes for the wrong reasons. To wire it all together, we'll use Ore, a lightweight dependency injection container for Go. And for persistence, we'll use SQLite via the modernc.org/sqlite driver (pure Go, no CGo required - your CI pipeline will thank you πŸ™).

By the end of this guide you'll have:

  • πŸ“¦ A proper 4-layer Clean Architecture project structure
  • πŸ—„οΈ A real SQLite database with schema migration on startup
  • πŸ’‰ Ore managing all dependency wiring, lifetimes, and startup validation
  • πŸ”Œ Graceful shutdown that closes the DB connection cleanly

Let's go.


πŸ—ΊοΈ The Big Picture

Before writing a single line of code, let's understand why Clean Architecture matters.

The core rule is simple: dependencies only point inward. The domain layer (your business rules) knows nothing about HTTP or SQLite. The application layer (your use cases) knows the domain but not the database driver. Infrastructure (your SQLite repo) knows the domain interface it implements, but the domain doesn't know SQLite exists.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Presentation (HTTP)              β”‚  ← knows application
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚           Application (Use Cases)            β”‚  ← knows domain
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚          Domain (Entities + Interfaces)       β”‚  ← knows nothing
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚         Infrastructure (SQLite, etc.)         β”‚  ← knows domain interfaces
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         ↑ Dependencies point inward ↑
Enter fullscreen mode Exit fullscreen mode

What makes this possible in Go is interfaces. The domain defines BookRepository as an interface. The infrastructure provides the concrete SQLite implementation. The application layer depends only on the interface. Ore acts as the composition root - the one place that says "okay, when someone needs a BookRepository, give them the SQLite one."

Here's the project layout we'll build:

booklib/
β”œβ”€β”€ main.go
β”œβ”€β”€ go.mod
β”œβ”€β”€ domain/
β”‚   └── book.go           # Entity + repository interface
β”œβ”€β”€ application/
β”‚   └── book_service.go   # Use cases
β”œβ”€β”€ infrastructure/
β”‚   └── sqlite_repo.go    # SQLite repository implementation
β”œβ”€β”€ presentation/
β”‚   └── book_handler.go   # HTTP handlers + route registration
└── di/
    └── container.go      # 🎯 The composition root - all Ore wiring lives here
Enter fullscreen mode Exit fullscreen mode

Notice di/container.go is the only file that imports all layers at once. Every other package stays in its lane.


βš™οΈ Step 1: Initialize the Project

mkdir booklib && cd booklib
go mod init booklib

# Ore for dependency injection
go get -u github.com/firasdarwish/ore

# Pure-Go SQLite driver - no CGo, no drama
go get modernc.org/sqlite
Enter fullscreen mode Exit fullscreen mode

πŸ’‘ Why modernc.org/sqlite over mattn/go-sqlite3? The mattn driver requires CGo, which complicates cross-compilation and Docker builds. modernc.org/sqlite is a pure Go port - it compiles and runs anywhere Go does.


🧠 Step 2: The Domain Layer - The Heart of the App

The domain layer is sacred. It contains your business entities and the contracts (interfaces) that describe what the app needs from the outside world. No imports from other layers. No Ore. No database/sql. Nothing.

// domain/book.go
package domain

import "errors"

// Book is our core entity. It belongs to the domain, not to any database table.
type Book struct {
    ID     int64
    Title  string
    Author string
}

// These are domain errors - they describe business-level failures,
// not HTTP status codes or SQL error codes.
var (
    ErrBookNotFound = errors.New("book not found")
    ErrInvalidBook  = errors.New("book title and author are required")
)

// BookRepository is the contract the domain needs from persistence.
// It doesn't know or care that SQLite is on the other side.
// Tomorrow it could be Postgres. The domain layer would never know.
type BookRepository interface {
    Save(book Book) (Book, error)
    FindByID(id int64) (Book, error)
    FindAll() ([]Book, error)
}
Enter fullscreen mode Exit fullscreen mode

This is the most important file in the project - and it has zero external dependencies. That's the point. Your business rules don't need SQLite to exist. They don't need net/http to exist. They just need a BookRepository that works.


πŸ”§ Step 3: The Application Layer - Where Use Cases Live

The application layer orchestrates your use cases. It takes user intentions ("create a book"), applies domain rules (validate input), delegates to the repository (persist it), and returns results. It knows the domain package. It does not know SQLite, HTTP, or anything infrastructure-related.

// application/book_service.go
package application

import (
    "booklib/domain"
    "context"

    "github.com/firasdarwish/ore"
)

// BookService contains all the use cases for the book feature.
type BookService struct {
    repo domain.BookRepository
}

// NewBookService is Ore's initializer for this service.
// Notice it asks Ore for domain.BookRepository - an interface.
// It has no idea that SQLite is behind it. 🀷
func NewBookService(ctx context.Context) (*BookService, context.Context) {
    repo, ctx := ore.Get[domain.BookRepository](ctx)
    return &BookService{repo: repo}, ctx
}

// CreateBook validates input and persists a new book.
func (s *BookService) CreateBook(title, author string) (domain.Book, error) {
    if title == "" || author == "" {
        return domain.Book{}, domain.ErrInvalidBook
    }
    return s.repo.Save(domain.Book{Title: title, Author: author})
}

// GetBook retrieves a book by its ID.
func (s *BookService) GetBook(id int64) (domain.Book, error) {
    return s.repo.FindByID(id)
}

// ListBooks returns all books in the library.
func (s *BookService) ListBooks() ([]domain.Book, error) {
    return s.repo.FindAll()
}
Enter fullscreen mode Exit fullscreen mode

The NewBookService initializer is doing something subtle but powerful: ore.Get[domain.BookRepository](ctx) asks Ore to resolve the BookRepository interface from the dependency graph. At this point in the code, we have absolutely no idea what the concrete type is. The DI container (di/container.go) will decide that later.


πŸ—„οΈ Step 4: The Infrastructure Layer - SQLite Gets Its Hands Dirty

Now for the fun part. The infrastructure layer is where we actually talk to SQLite. This package knows it's implementing domain.BookRepository. It knows about database/sql. It knows about SQL queries. The domain and application layers remain blissfully unaware of all this.

// infrastructure/sqlite_repo.go
package infrastructure

import (
    "booklib/domain"
    "context"
    "database/sql"
    "errors"
    "log"

    "github.com/firasdarwish/ore"
    _ "modernc.org/sqlite" // registers the "sqlite" driver
)

// DB is a thin wrapper around *sql.DB so Ore can manage its lifetime
// as a distinct type in the container.
type DB struct {
    *sql.DB
}

// NewDB opens the SQLite connection and runs schema migrations.
// Registered as a Singleton - one DB pool for the entire app lifetime.
func NewDB(ctx context.Context) (*DB, context.Context) {
    sqlDB, err := sql.Open("sqlite", "./books.db")
    if err != nil {
        log.Fatalf("❌ failed to open SQLite: %v", err)
    }

    // Tune the connection pool for SQLite's single-writer model
    sqlDB.SetMaxOpenConns(1)
    sqlDB.SetMaxIdleConns(1)

    if err := sqlDB.Ping(); err != nil {
        log.Fatalf("❌ SQLite ping failed: %v", err)
    }

    // Run schema migration - create the table if it doesn't exist
    _, err = sqlDB.Exec(`
        CREATE TABLE IF NOT EXISTS books (
            id     INTEGER PRIMARY KEY AUTOINCREMENT,
            title  TEXT    NOT NULL,
            author TEXT    NOT NULL
        )
    `)
    if err != nil {
        log.Fatalf("❌ failed to run migrations: %v", err)
    }

    log.Println("βœ… SQLite connected and schema ready")
    return &DB{sqlDB}, ctx
}

// Shutdown closes the database connection.
// Ore will call this at application exit via GetResolvedSingletons[Shutdowner]().
func (db *DB) Shutdown() {
    log.Println("πŸ”Œ Closing SQLite connection...")
    if err := db.Close(); err != nil {
        log.Printf("⚠️  error closing DB: %v", err)
    }
}

// SQLiteBookRepository is the concrete implementation of domain.BookRepository.
type SQLiteBookRepository struct {
    db *DB
}

// NewSQLiteBookRepository is Ore's initializer for the repository.
// It resolves *DB from the container - Ore ensures it's the same singleton instance.
func NewSQLiteBookRepository(ctx context.Context) (*SQLiteBookRepository, context.Context) {
    db, ctx := ore.Get[*DB](ctx)
    return &SQLiteBookRepository{db: db}, ctx
}

func (r *SQLiteBookRepository) Save(book domain.Book) (domain.Book, error) {
    result, err := r.db.Exec(
        "INSERT INTO books (title, author) VALUES (?, ?)",
        book.Title, book.Author,
    )
    if err != nil {
        return domain.Book{}, err
    }
    id, err := result.LastInsertId()
    if err != nil {
        return domain.Book{}, err
    }
    book.ID = id
    return book, nil
}

func (r *SQLiteBookRepository) FindByID(id int64) (domain.Book, error) {
    row := r.db.QueryRow("SELECT id, title, author FROM books WHERE id = ?", id)
    var book domain.Book
    if err := row.Scan(&book.ID, &book.Title, &book.Author); err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return domain.Book{}, domain.ErrBookNotFound
        }
        return domain.Book{}, err
    }
    return book, nil
}

func (r *SQLiteBookRepository) FindAll() ([]domain.Book, error) {
    rows, err := r.db.Query("SELECT id, title, author FROM books ORDER BY id ASC")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var books []domain.Book
    for rows.Next() {
        var book domain.Book
        if err := rows.Scan(&book.ID, &book.Title, &book.Author); err != nil {
            return nil, err
        }
        books = append(books, book)
    }
    return books, rows.Err()
}
Enter fullscreen mode Exit fullscreen mode

A few design decisions worth calling out:

The DB wrapper type exists so that Ore can track *DB as a distinct named type in the container. Without the wrapper, we'd be registering *sql.DB - which is fine, but less descriptive and harder to extend later (e.g. if you wanted to add health check methods).

SetMaxOpenConns(1) is important for SQLite. It's a file-based, single-writer database - opening many concurrent connections is counterproductive and can cause locking errors. One connection, properly managed, is the right call.

The Shutdown() method is our exit hook. Ore will find this automatically when we call ore.GetResolvedSingletons[Shutdowner]() in main.go.


🌐 Step 5: The Presentation Layer - HTTP Talks to the World

The presentation layer handles the HTTP surface of the app. It speaks JSON in, JSON out. It knows the application layer (to call use cases) but nothing about SQLite, domain errors beyond what it needs to map, or any infrastructure concern.

// presentation/book_handler.go
package presentation

import (
    "booklib/application"
    "booklib/domain"
    "context"
    "encoding/json"
    "errors"
    "net/http"
    "strconv"

    "github.com/firasdarwish/ore"
)

// BookHandler holds all the HTTP handler methods for books.
type BookHandler struct {
    service *application.BookService
}

// NewBookHandler is Ore's initializer - it resolves *BookService from the container.
func NewBookHandler(ctx context.Context) (*BookHandler, context.Context) {
    svc, ctx := ore.Get[*application.BookService](ctx)
    return &BookHandler{service: svc}, ctx
}

func (h *BookHandler) CreateBook(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Title  string `json:"title"`
        Author string `json:"author"`
    }
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        http.Error(w, "invalid JSON body", http.StatusBadRequest)
        return
    }

    book, err := h.service.CreateBook(input.Title, input.Author)
    if err != nil {
        if errors.Is(err, domain.ErrInvalidBook) {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        http.Error(w, "something went wrong", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(book)
}

func (h *BookHandler) GetBook(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id")
    id, err := strconv.ParseInt(idStr, 10, 64)
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    book, err := h.service.GetBook(id)
    if err != nil {
        if errors.Is(err, domain.ErrBookNotFound) {
            http.Error(w, "book not found", http.StatusNotFound)
            return
        }
        http.Error(w, "something went wrong", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(book)
}

func (h *BookHandler) ListBooks(w http.ResponseWriter, r *http.Request) {
    books, err := h.service.ListBooks()
    if err != nil {
        http.Error(w, "something went wrong", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(books)
}

// RegisterRoutes wires each route to the handler.
// The handler itself is resolved fresh per request via r.Context() -
// this means BookHandler and BookService are Scoped to each HTTP request.
func RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("POST /books", func(w http.ResponseWriter, r *http.Request) {
        handler, _ := ore.Get[*BookHandler](r.Context())
        handler.CreateBook(w, r)
    })
    mux.HandleFunc("GET /books/{id}", func(w http.ResponseWriter, r *http.Request) {
        handler, _ := ore.Get[*BookHandler](r.Context())
        handler.GetBook(w, r)
    })
    mux.HandleFunc("GET /books", func(w http.ResponseWriter, r *http.Request) {
        handler, _ := ore.Get[*BookHandler](r.Context())
        handler.ListBooks(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

The route closures each call ore.Get[*BookHandler](r.Context()). This is the scoping magic at work - Ore uses r.Context() as the scope boundary. Every concurrent request gets its own BookHandler and its own BookService, completely isolated. The shared singleton *DB underneath is reused across all of them.


πŸ’‰ Step 6: The DI Container - The Composition Root

This is the most important wiring file in the whole project. di/container.go is the only place that imports all layers simultaneously. It's where you make the concrete choices: "use SQLite, not Postgres", "use this service, not a mock."

// di/container.go
package di

import (
    "booklib/application"
    "booklib/domain"
    "booklib/infrastructure"
    "booklib/presentation"

    "github.com/firasdarwish/ore"
)

// Register wires the entire application dependency graph into Ore.
// This is the composition root - the one place that sees all layers.
func Register() {
    // πŸ—„οΈ Register the SQLite DB as a Singleton.
    // One *DB instance for the whole application lifetime.
    // NewDB opens the connection and runs migrations on first resolution.
    ore.RegisterFunc[*infrastructure.DB](
        ore.Singleton,
        infrastructure.NewDB,
    )

    // πŸ“¦ Register the SQLite repository as a Singleton.
    // It depends on *DB, which Ore resolves above.
    ore.RegisterFunc[*infrastructure.SQLiteBookRepository](
        ore.Singleton,
        infrastructure.NewSQLiteBookRepository,
    )

    // πŸ”— Alias: whenever someone asks for domain.BookRepository (the interface),
    // Ore hands them *infrastructure.SQLiteBookRepository (the implementation).
    // This is dependency inversion - the application layer never knows SQLite exists.
    ore.RegisterAlias[domain.BookRepository, *infrastructure.SQLiteBookRepository]()

    // ⚑ Register BookService as Scoped - a fresh instance per request context.
    // It depends on domain.BookRepository, resolved via the alias above.
    ore.RegisterFunc[*application.BookService](
        ore.Scoped,
        application.NewBookService,
    )

    // 🌐 Register BookHandler as Scoped - a fresh instance per request context.
    // It depends on *application.BookService.
    ore.RegisterFunc[*presentation.BookHandler](
        ore.Scoped,
        presentation.NewBookHandler,
    )

    // πŸ”’ Seal the container - no new registrations allowed after this point.
    // Any attempt to register after Seal() will panic immediately,
    // preventing accidental runtime mutation of the dependency graph.
    ore.Seal()

    // βœ… Validate the full dependency graph at startup.
    // Ore will catch:
    //   - Missing dependencies
    //   - Circular dependencies
    //   - Lifetime misalignments (e.g. a Singleton depending on a Scoped)
    // Better to crash loudly on startup than silently misbehave at 3am. πŸ”₯
    ore.Validate()

    // ⚑ Disable per-call validation now that we've verified everything.
    // Ore validates on every Get() call by default - useful in development,
    // but unnecessary overhead once Validate() has blessed the graph.
    ore.DisableValidation = true
}
Enter fullscreen mode Exit fullscreen mode

The RegisterAlias line is the crux of Clean Architecture in code form. BookService calls ore.Get[domain.BookRepository](ctx) - asking for an interface. The alias tells Ore: "that interface? Give them *SQLiteBookRepository." If tomorrow you want to swap to Postgres, you change two lines in this file and nothing else in the project changes.


πŸš€ Step 7: main.go - Startup, Routes, and Goodbye

// main.go
package main

import (
    "booklib/di"
    "booklib/presentation"
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/firasdarwish/ore"
)

// Shutdowner is our graceful-shutdown interface.
// Any singleton that implements Shutdown() will be discovered automatically by Ore.
// We define it here in main - Ore doesn't care where it's defined,
// only that the type matches at runtime.
type Shutdowner interface {
    Shutdown()
}

func main() {
    log.Println("πŸ“š BookLib starting up...")

    // Wire up everything - this is the only line in main that cares about DI
    di.Register()

    // Set up routes
    mux := http.NewServeMux()
    presentation.RegisterRoutes(mux)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    // Start serving in a goroutine so we can listen for shutdown signals below
    go func() {
        log.Println("🌍 Server listening on http://localhost:8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("❌ server crashed: %v", err)
        }
    }()

    // Block until we receive SIGINT or SIGTERM (Ctrl+C or `kill`)
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("πŸ‘‹ Shutdown signal received - draining requests...")

    // Give in-flight requests 5 seconds to finish
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := server.Shutdown(ctx); err != nil {
        log.Printf("⚠️  HTTP shutdown error: %v", err)
    }

    // πŸ”‘ Graceful service cleanup via Ore.
    // GetResolvedSingletons finds every resolved singleton that implements Shutdowner.
    // Crucially, Ore returns them in REVERSE dependency order -
    // so the DB is closed AFTER any services that depend on it.
    // No manual enumeration. No forgetting a service. Ore handles it. πŸ’ͺ
    log.Println("🧹 Cleaning up singletons...")
    shutdownables := ore.GetResolvedSingletons[Shutdowner]()
    for _, s := range shutdownables {
        s.Shutdown()
    }

    log.Println("βœ… Clean exit. Goodbye!")
}
Enter fullscreen mode Exit fullscreen mode

The shutdown sequence is worth reading twice. We don't call db.Close() explicitly anywhere in main.go. We don't maintain a list of "things to clean up." We just ask Ore: "give me every resolved singleton that can shut itself down, in dependency order." Ore knows the graph - it knows *DB was resolved as a dependency of *SQLiteBookRepository, so *DB.Shutdown() runs after SQLiteBookRepository is done. As you add more singletons (caches, queue publishers, audit log flushers), they just need to implement Shutdown() and Ore includes them automatically.


πŸ§ͺ Let's Take It for a Spin

go run main.go
# πŸ“š BookLib starting up...
# βœ… SQLite connected and schema ready
# 🌍 Server listening on http://localhost:8080
Enter fullscreen mode Exit fullscreen mode

In another terminal:

# βž• Create a few books
curl -s -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"The Go Programming Language","author":"Donovan & Kernighan"}' | jq

# {
#   "ID": 1,
#   "Title": "The Go Programming Language",
#   "Author": "Donovan & Kernighan"
# }

curl -s -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"Clean Architecture","author":"Robert C. Martin"}' | jq

# πŸ“š List all books
curl -s http://localhost:8080/books | jq

# πŸ” Get a specific book
curl -s http://localhost:8080/books/1 | jq

# ❌ Try a bad request
curl -s -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":""}' | jq
# book title and author are required

# Press Ctrl+C:
# πŸ‘‹ Shutdown signal received - draining requests...
# 🧹 Cleaning up singletons...
# πŸ”Œ Closing SQLite connection...
# βœ… Clean exit. Goodbye!
Enter fullscreen mode Exit fullscreen mode

Restart the server and the books are still there - they're in books.db, a real SQLite file on disk.


πŸ”¬ How Ore Sees the Dependency Graph

Here's what happens under the hood when a GET /books/1 request comes in:

r.Context()
    β”‚
    β–Ό  ore.Get[*BookHandler](r.Context())
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  [Scoped] *BookHandler           β”‚  ← fresh per request
β”‚    depends on ↓                  β”‚
β”‚  [Scoped] *BookService           β”‚  ← fresh per request
β”‚    depends on ↓                  β”‚
β”‚  [Alias]  domain.BookRepository  β”‚  ← resolves to ↓
β”‚  [Singleton] *SQLiteBookRepo     β”‚  ← shared across all requests
β”‚    depends on ↓                  β”‚
β”‚  [Singleton] *DB                 β”‚  ← one connection pool, always
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

The two singletons at the bottom are created once, ever. The scoped services are created fresh for each request and garbage collected when the request context ends. Ore manages all of this from the registrations in di/container.go. You never call new() manually for any of these types.


πŸ”„ The Payoff: Swapping SQLite for Postgres

Remember when we said this architecture makes swapping databases a two-line change? Here's the proof. Add a PostgresBookRepository to infrastructure, then in di/container.go:

// Before (SQLite):
ore.RegisterFunc[*infrastructure.SQLiteBookRepository](ore.Singleton, infrastructure.NewSQLiteBookRepository)
ore.RegisterAlias[domain.BookRepository, *infrastructure.SQLiteBookRepository]()

// After (Postgres):
ore.RegisterFunc[*infrastructure.PostgresBookRepository](ore.Singleton, infrastructure.NewPostgresBookRepository)
ore.RegisterAlias[domain.BookRepository, *infrastructure.PostgresBookRepository]()
Enter fullscreen mode Exit fullscreen mode

BookService, BookHandler, your tests, your domain rules - none of them change. Not a single line. That's the whole point. 🎯


πŸ§ͺ Testing with Isolated Containers

One of Ore's killer features for testing is ore.NewContainer(). Each test gets its own isolated dependency graph - no shared state, no flaky tests, no "it passes locally but fails in CI" mysteries.

func TestCreateBook(t *testing.T) {
    // 🧱 Build a fresh, isolated container just for this test
    container := ore.NewContainer()

    // Register a mock repo instead of the real SQLite one
    ore.RegisterFuncToContainer[domain.BookRepository](container, ore.Scoped,
        func(ctx context.Context) (domain.BookRepository, context.Context) {
            return &MockBookRepository{}, ctx
        },
    )

    // Wire up BookService - same initializer as production
    ore.RegisterFuncToContainer[*application.BookService](container, ore.Scoped,
        application.NewBookService,
    )

    ctx := context.Background()
    svc, _ := ore.GetFromContainer[*application.BookService](container, ctx)

    book, err := svc.CreateBook("Clean Code", "Robert Martin")

    assert.NoError(t, err)
    assert.Equal(t, "Clean Code", book.Title)
    assert.Equal(t, "Robert Martin", book.Author)
}
Enter fullscreen mode Exit fullscreen mode

The mock repo satisfies domain.BookRepository. BookService doesn't know or care. The test runs fast with zero disk I/O. πŸš€


πŸ’‘ Ore Patterns You'll Use Again and Again

By building this app, you've touched the core toolkit:

RegisterFunc with ore.Singleton - for anything expensive to create and safe to share: DB connections, HTTP clients, config structs.

RegisterFunc with ore.Scoped - for anything that should be isolated per request: services, handlers, transaction managers.

RegisterAlias - the dependency inversion trick. Register a concrete, alias an interface to it. Every caller gets the interface; Ore handles the mapping.

ore.Seal() + ore.Validate() - your safety net. Call them both at startup. Catch misconfigured graphs before the first request, not during a production incident.

ore.GetResolvedSingletons[T]() - the graceful shutdown pattern. Define a cleanup interface, let your services implement it, and Ore gives you a sorted list at exit time. No maintenance required as the app grows.


🏁 Wrapping Up

Here's what we built and why it matters:

The domain layer has zero external dependencies. Your business rules could be tested in a brand new Go project with just the standard library. That's power.

The application layer depends only on domain interfaces. It doesn't know if it's talking to SQLite, Postgres, a mock, or a remote API. It just does its job.

The infrastructure layer does the dirty work - SQL queries, connection management, schema migrations - and implements exactly the interface the domain asked for.

The presentation layer translates HTTP to application calls and back. If you add gRPC next month, it's just a new presentation file.

The DI container is the only place that sees the whole picture. Changing the database is two lines. Swapping to a mock in tests is five lines. The rest of the project never notices. 😌

Clean Architecture and Ore are a natural match. Ore makes the composition root explicit, validated, and lifecycle-aware. Clean Architecture gives every piece of your app a clear home and clear rules for how it can talk to its neighbors.

Now go build something real. πŸš€


Full Ore documentation: ore.lilury.com - source on GitHub

Top comments (0)