← Back to list

Testing a Hexagonal Go App: From Domain to Database

This post is the fourth in a series. Part 1 was about folder structure and where the boundary between domain and adapter actually lives…

Mustafa Yılmaz · 2026-06-06 09:13 · 0 claps · 12.5 min read
#golang #software-testing #software-architecture #hexagonal-architecture #backend-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🏛️ · Architecture

Testing a Hexagonal Go App: From Domain to Database

This post is the fourth in a series. Part 1 was about folder structure and where the boundary between domain and adapter actually lives. Part 2 was about keeping the database schema out of the domain. Part 3 was about coordinating writes without leaking transactions into the domain.

This isn’t a war story. There’s no incident, no on-call page, no postmortem. The reason I’m writing this post is something quieter: I noticed that the architectural choices from the first three posts kept making certain tests feel obvious and others feel forced. That asymmetry was the lesson. The patterns weren’t just about “clean code.” They were paying for themselves every time I sat down to write a test.

What I want to do here is walk through what testing actually looks like across the layers of a hexagonal Go application, and why most Go codebases I’ve seen get the ratio of test types wrong.

The Wrong Way to Read This Post

Before I go further: this isn’t a “100% coverage” post. I’m not going to argue that more tests are always better, or that you should mock every dependency, or that integration tests are bad. The argument is narrower than that.

The argument is that each layer of a hexagonal application has a different testing job, and trying to do all of those jobs with one type of test is what makes test suites slow, brittle, and ultimately useless. The architecture from the previous three posts gives you four natural layers. Each one wants a different kind of test. When you respect that, the suite stays fast and the tests stay meaningful.

Let me show you what I mean.

Four Layers, Four Test Types

Recall the structure from Part 1:

internal/chain/
├── domain/              # entities, value objects, ports
├── application/         # use cases (orchestration)
├── adapter/
│   ├── driving/http/    # HTTP handlers
│   └── driven/postgres/ # repository implementations

Each of these layers exists to do one thing. Domain holds business rules. Application orchestrates them. Driving adapters translate from the outside world into use case calls. Driven adapters translate use case decisions into infrastructure operations.

If each layer does one thing, each layer can be tested for that one thing — and only that one thing. The mistake I see most often is testing business logic at the handler level, or testing HTTP behavior at the use case level. When tests reach across layers, they end up testing too much, taking too long, and breaking for the wrong reasons.

Here’s the mapping:

The tests get progressively more expensive as you go down the table. Domain tests run in milliseconds with no setup. Use case tests take a few milliseconds because mocks are cheap. Repository tests take seconds because they spin up a container. Handler tests are fast again because they mock the use case.

The bulk of the suite (by count, not by time) sits at the top. That’s the point. Most of what you want to verify is in the domain, and the domain is the cheapest place to verify it.

Let me walk through each layer with real code.

Domain Tests: Zero Dependencies, Milliseconds

The domain layer is where business rules live. In Chainfy, that includes things like: when does a check-in count toward a streak? Is this date inside the recovery window? Can this chain be activated given its current state?

Here’s a domain entity behavior I want to verify:

// internal/chain/domain/chain.go
package domain

func (c *Chain) CanRecover(date time.Time, now time.Time) error {
    daysDiff := int(now.Sub(date).Hours() / 24)
    if daysDiff < 1 || daysDiff > 7 {
        return ErrDateOutOfRecoveryWindow
    }

    dateNormalized := normalizeDate(date)
    chainCreated := normalizeDate(c.CreatedAt)
    if dateNormalized.Before(chainCreated) {
        return ErrDateBeforeChainCreation
    }

    dayOfWeek := int(date.Weekday())
    if !slices.Contains(c.DaysOfWeek, dayOfWeek) {
        return ErrDateNotOnScheduledDay
    }

    return nil
}  

This method has no dependencies. No database, no HTTP, no time. (I pass now in explicitly; that's a pattern I'll return to in a moment.) The test for it is correspondingly clean:

// internal/chain/domain/chain_test.go
package domain_test

func TestChain_CanRecover(t *testing.T) {
    now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)

    chain := &domain.Chain{
        CreatedAt:  now.AddDate(0, -1, 0),
        DaysOfWeek: []int{1, 2, 3, 4, 5}, // weekdays
    }

    tests := []struct {
        name    string
        date    time.Time
        wantErr error
    }{
        {
            name:    "valid recovery, 3 days ago",
            date:    now.AddDate(0, 0, -3),
            wantErr: nil,
        },  
        {  
            name:    "outside 7-day window",
            date:    now.AddDate(0, 0, -10),
            wantErr: domain.ErrDateOutOfRecoveryWindow,
        },
        {
            name:    "weekend, not a scheduled day",
            date:    time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC), // Saturday
            wantErr: domain.ErrDateNotOnScheduledDay,
        },
        {
            name:    "before chain was created",
            date:    now.AddDate(0, -2, 0),
            wantErr: domain.ErrDateBeforeChainCreation,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := chain.CanRecover(tt.date, now)
            require.ErrorIs(t, err, tt.wantErr)
        })
    }
}

No setup. No mocks. No testcontainers. The whole test file runs in single-digit milliseconds.

This is only possible because the domain entity doesn’t carry any infrastructure baggage: no db tag (Part 2), no executor parameter (Part 3), no framework annotations. Every test for CanRecover is a test of the rule, not of the rule plus its environment.

Two patterns worth calling out:

Inject now instead of calling time.Now(). This is a small change with a big payoff. If CanRecover called time.Now() internally, every test would have to either freeze the clock with a library or accept flakiness. Passing the clock as a parameter makes the function pure with respect to time, which makes the tests deterministic for free.

Use table-driven tests for branching logic. The four scenarios above all exercise the same method through different code paths. Writing them as a table makes the space of cases visible. You can see at a glance what’s covered and what isn’t, and adding a fifth case is one struct entry, not a new test function.

The bulk of a healthy hexagonal Go test suite (by count) should look like this. Cheap, fast, no setup, exhaustive across the rules.

Use Case Tests: Mocked Ports, Verify Orchestration

Use case tests are different. The use case isn’t where business rules live — it’s where they’re coordinated. So use case tests verify orchestration: the right repositories were called in the right order with the right arguments, and the right error was returned in the right failure case.

Take the RecoverStreak use case from Part 3, in its final form (with the transaction manager):

func (s *ChainService) RecoverStreak(
    ctx context.Context,
    userID uuid.UUID,
    chainID uuid.UUID,
    date time.Time,
) (*domain.ChainProgress, error) {
    // ... validation ...

    var progress *domain.ChainProgress
    err := s.txManager.Run(ctx, func(ctx context.Context) error {
        // mark progress completed
        // recalculate streaks
        return nil
    })
    // ...
}

What we want the test to verify: when recovery is requested for a valid date, the use case calls the progress repository to mark the day completed, then calls the chain repository to recalculate streaks, both inside a transaction. We don’t want to verify what CanRecover does or what RecalculateStreaks does. Those are domain and repository concerns, tested elsewhere.

Here’s what that looks like with mocked ports:

// internal/chain/application/chain_service_test.go
package application_test

func TestChainService_RecoverStreak_Success(t *testing.T) {
    ctx := context.Background()
    chainID := uuid.New()
    userID := uuid.New()
    date := time.Now().AddDate(0, 0, -2)

    // Mocks generated by mockery from the port interfaces.
    chainRepo := mocks.NewChainRepository(t)
    progressRepo := mocks.NewChainProgressRepository(t)
    subLimits := mocks.NewSubscriptionLimits(t)
    txManager := mocks.NewTransactionManager(t)

    chain := &domain.Chain{
        ID:         chainID,
        UserID:     userID,
        CreatedAt:  time.Now().AddDate(0, -1, 0),
        DaysOfWeek: []int{1, 2, 3, 4, 5},
    }

    subLimits.On("CanAccessFeature", ctx, userID, "streak_recovery").
        Return(true, nil)
    chainRepo.On("GetByID", ctx, chainID).Return(chain, nil)
    progressRepo.On("GetByDateRange", ctx, chainID, date, date).
        Return([]*domain.ChainProgress{}, nil)

    // The transaction manager runs the function it's given immediately.
    // We're not testing the transaction itself — that's a Postgres concern.
    txManager.On("Run", ctx, mock.AnythingOfType("func(context.Context) error")).
        Run(func(args mock.Arguments) {
            fn := args.Get(1).(func(context.Context) error)
            _ = fn(ctx)
        }).
        Return(nil)

    progressRepo.On("Create", ctx, mock.AnythingOfType("*domain.ChainProgress")).
        Return(nil)
    chainRepo.On("RecalculateStreaks", ctx, chainID).Return(nil)

    svc := application.NewChainService(chainRepo, progressRepo, subLimits, txManager)

    progress, err := svc.RecoverStreak(ctx, userID, chainID, date)

    require.NoError(t, err)
    require.NotNil(t, progress)
    require.True(t, progress.Completed)
}

A few things are happening here that are worth slowing down on.

The mocks come from port interfaces, not implementations. This is the payoff of Part 1’s structural choice. ChainRepository is an interface defined in the domain layer; mockery generates a mock from it. The use case test never sees Postgres, never sees *sql.DB, never sees a transaction. It sees only the contract.

The transaction manager is mocked as a passthrough. The mock is configured to run the function it’s given and return whatever the function returns. We’re not testing that the transaction commits or rolls back; that’s the transaction manager’s responsibility, and it has its own tests at the adapter level. We’re testing that the use case uses the transaction manager correctly, that the two writes happen inside the wrapper.

The test doesn’t verify business rules. I don’t write a use case test that says “passing a date 10 days ago should fail.” That test exists in domain.TestChain_CanRecover. Putting it here too would be duplication, and worse, it would couple the use case test to the domain logic. Every time I added a new validation rule, I'd have to update both the domain test and the use case test.

The use case test exists to verify flow. Did we check the subscription? Did we fetch the chain? Did we mark progress before recalculating? Did we wrap both in a transaction? That’s the orchestration contract.

I write the failure-case tests with the same shape:

func TestChainService_RecoverStreak_NoSubscription(t *testing.T) {
    ctx := context.Background()
    userID := uuid.New()
    chainID := uuid.New()

    subLimits := mocks.NewSubscriptionLimits(t)
    subLimits.On("CanAccessFeature", ctx, userID, "streak_recovery").
        Return(false, nil)

    svc := application.NewChainService(nil, nil, subLimits, nil)

    _, err := svc.RecoverStreak(ctx, userID, chainID, time.Now())

    require.ErrorIs(t, err, domain.ErrForbidden)
}

Note that chainRepo, progressRepo, and txManager are all nil here. The test asserts that we never reach them, and mockery's strict mode would fail if any of them were called. The test is small, fast, and unambiguous about what it's verifying: subscription failure short-circuits everything else.

Repository Tests: Real Postgres, Tagged Separately

Repository tests are the expensive ones. They have to actually talk to a database, because what they verify is the translation between Go and SQL: the mapper round-trip, the partial unique index behavior, the JSONB query, the things that you can only know are right by running them against a real Postgres.

I keep these in a separate file with a build tag so they don’t run on every go test ./...:

//go:build integration

package postgres_test

func TestChainRepository_RecalculateStreaks(t *testing.T) {
    ctx := context.Background()
    db := setupTestDB(t) // testcontainers-go spins up a real Postgres
    repo := postgres.NewChainRepository(db)

    chain := seedChain(t, db, /* ... */)
    seedProgress(t, db, chain.ID, /* ... 5 completed days ... */)

    err := repo.RecalculateStreaks(ctx, chain.ID)
    require.NoError(t, err)

    updated, err := repo.GetByID(ctx, chain.ID)
    require.NoError(t, err)
    require.Equal(t, 5, updated.CurrentStreak)
    require.Equal(t, 5, updated.TotalCompletions)
}

This test takes seconds, not milliseconds. The container has to start, the schema has to be migrated, the seed data has to be inserted. That’s fine — because there are far fewer of these tests than there are domain tests.

The mistake I see is using repository tests to verify business logic. “Let me write an integration test for the recovery flow,” and now you have a test that spins up Postgres, hits a real HTTP server, does the recovery, and checks the streak. That test is slow, brittle, and tests four layers at once. When it fails, you don’t know which layer broke.

The repository test should test exactly one thing: does this repository method correctly translate between the Go model and the database? That’s it. The streak recalculation logic (chain.CalculateStreaks(progress)) is tested in the domain layer with no database. The repository test just verifies that the result of that calculation is correctly written to and read from Postgres.

A few practical notes:

Build tags matter. //go:build integration keeps these tests out of the default go test ./... run. Developers can run unit tests in milliseconds during normal work, and run integration tests with go test -tags=integration ./... before pushing. CI runs both, but in separate jobs: the unit tests block PRs immediately, the integration tests run in parallel.

One container per test package, not per test. Spinning up a Postgres container for every test would push the suite into the multi-minute range. Instead, the package’s TestMain starts one container, runs all tests against it (with each test using its own schema or transaction-rolled-back state), and tears it down at the end.

testcontainers-go is worth the dependency. The alternative (assuming a Postgres is already running locally) works on your machine and breaks in CI, on every new contributor’s machine, and any time someone bumps the Postgres version. Containers eliminate that whole class of “works on my machine” issue.

Handler Tests: HTTP Concerns Only

Handler tests are the smallest layer, and they should stay that way.

The handler’s job is to translate between HTTP and the use case: parse the request, extract auth, call the use case, map errors to status codes, serialize the response. That’s it. There should be no business logic in the handler, and the handler test should reflect that.

// internal/chain/adapter/driving/http/recover_streak_test.go
func TestRecoverStreakHandler_Success(t *testing.T) {
    svc := mocks.NewChainService(t)
    svc.On("RecoverStreak", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
        Return(&domain.ChainProgress{Completed: true}, nil)

    h := http.NewRecoverStreakHandler(svc)
    req := httptest.NewRequest("POST", "/chains/123/recover",
        strings.NewReader(`{"date": "2026-05-04T00:00:00Z"}`))
    rr := httptest.NewRecorder()

    h.ServeHTTP(rr, req)

    require.Equal(t, 200, rr.Code)
    require.Contains(t, rr.Body.String(), `"completed":true`)
}

func TestRecoverStreakHandler_ForbiddenMapsTo403(t *testing.T) {
    svc := mocks.NewChainService(t)
    svc.On("RecoverStreak", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
        Return(nil, domain.ErrForbidden)

    h := http.NewRecoverStreakHandler(svc)
    req := httptest.NewRequest("POST", "/chains/123/recover",
        strings.NewReader(`{"date": "2026-05-04T00:00:00Z"}`))
    rr := httptest.NewRecorder()

    h.ServeHTTP(rr, req)

    require.Equal(t, 403, rr.Code)
}

These tests run as fast as domain tests, because there’s no real I/O (httptest is in-memory). They cover the handler-specific concerns: status code mapping, request parsing, authentication header extraction, response serialization. They don't cover business logic, because business logic isn't here.

Most of my handler tests look almost identical. Two or three variations on the success path, plus one test per error type to verify the status code mapping. The behavior of the underlying use case is verified at the use case layer.

The Ratio That Matters

Here’s the shape of a healthy hexagonal Go test suite, by count:

Domain tests:          many — milliseconds each
Use case tests:        many — milliseconds each
Handler tests:         several per endpoint — milliseconds each
Repository tests:      few per repository — seconds each
End-to-end tests:      a handful — multiple seconds each

The bulk is at the top. The cost is at the bottom. The more rules and edge cases you have, the more domain tests grow — but they grow in the cheapest layer, so the suite stays fast.

The pattern I see in Go codebases that haven’t been built this way is the inverse: a small number of slow integration tests that try to cover everything by exercising the whole stack. Those suites end up at five, ten, fifteen minutes. They’re brittle because every test depends on every layer working. They’re expensive because every assertion costs a database round-trip. And they’re confusing when they fail, because the failure could be in any of four places.

This isn’t a pure Go problem. Every layered architecture in every language can fall into this trap. But Go is particularly susceptible because the language doesn’t enforce architectural boundaries. Nothing stops you from writing a test that imports the HTTP handler, builds a real database connection, and checks streak logic. The compiler is fine with it. The discipline has to come from somewhere else.

When Stratified Testing Doesn’t Pay

I want to be honest: this whole approach has overhead, and there are projects where it’s not worth it.

If you’re writing a small CRUD service with no real domain logic (endpoints that map directly to database rows), you don’t have a domain layer to test. You have controllers and a database. The “stratified” testing approach collapses into “controller tests with a real database,” which is exactly the pattern I just argued against in larger applications, but here it’s correct because there’s nothing else to test.

The same goes for prototypes, internal admin panels, and anything where the business logic lives in someone’s head rather than in the code. If chain.CanRecover is just three SQL constraints, there's no domain method to test. Adding one purely to have something to unit-test is making your code worse to satisfy a testing pattern.

The argument for stratified testing scales with the amount of business logic that lives in the domain layer. In a habit-tracking app, that’s a lot: streaks, recovery windows, scheduling, subscription gates. In a CRUD wrapper around a single table, it’s nothing. The tests should fit the architecture, not the other way around.

Looking Back at the Series

This is the last post in the series, so it’s worth saying out loud what the four posts were actually about, because it wasn’t really four separate topics.

Part 1 was about where the boundary between domain and infrastructure lives. Part 2 was about keeping the database schema from leaking across that boundary. Part 3 was about coordinating writes without leaking transactions across it either. And this post was about testing, which is where you find out whether the boundary was real.

That’s the thread. Every post was about the same line, drawn in a different place. A db tag on a domain struct crosses it. A *sql.Tx in a port signature crosses it. A test that imports the HTTP handler to check streak logic crosses it. The architecture isn't the folder structure; it's the discipline of not crossing the line, and the folder structure is just what makes crossing it visible.

The reason testing is the right place to end is that testing is the honest auditor of all of it. You can draw clean diagrams and still write a domain entity that secretly depends on the database. But the moment you try to test that entity without a database and can’t, the leak is exposed. A test that needs a container to verify a business rule is telling you the rule isn’t really in the domain. The friction you feel writing the test is the architecture grading itself.

I started this series by admitting I don’t have a definitive answer on Go architecture, and I’ll end the same way. What I have is a set of decisions that held up under the one pressure that matters: real users hitting real edge cases, like the streak recovery bug that opened Part 3. The patterns earned their place by surviving contact with production, not by looking good in a blog post. Yours will have to earn their place the same way, and they might land somewhere different from mine. That’s fine. The point was never the specific shape; it was learning to feel where the line is.

Thanks for reading all four. If you take one thing from the series, let it be the test for the boundary: when something feels hard to test in isolation, don’t reach for a bigger test. Ask what crossed the line.

I’d be curious to hear where your own architecture landed differently, and why. The decisions I made fit a habit-tracking app maintained by one person; yours fit something else, and the differences are usually the interesting part.


메타데이터
post_id
d22a20e5a149
slug
testing-a-hexagonal-go-app-from-domain-to-database-d22a20e5a149
url
https://medium.com/@codermuss/testing-a-hexagonal-go-app-from-domain-to-database-d22a20e5a149
canonical_url
https://medium.com/@codermuss/testing-a-hexagonal-go-app-from-domain-to-database-d22a20e5a149
author_url
https://medium.com/@codermuss
status
ok
fetched_at
2026-06-09 15:37:30