background-shape
Use Cases in Go, Coordinating Domain Logic
June 15, 2022 · 4 min read · by Muhammad Amal programming

TL;DR — A use case is a struct with an Execute method that orchestrates domain operations via injected dependencies. One use case per operation (StartSubscription, CancelSubscription). Input/output DTOs at the boundary. Tests run in milliseconds with hand-rolled fakes.

After domain and repositories, use cases are the layer that glues them. Each use case represents one user-facing operation. Reads like documentation of what the service does.

The shape

package usecase

import (
    "context"
    "github.com/google/uuid"
    "yourorg/billing/internal/domain"
)

type StartSubscriptionInput struct {
    CustomerID uuid.UUID
    PlanID     string
}

type StartSubscriptionOutput struct {
    Subscription *domain.Subscription
}

type StartSubscription struct {
    subs   SubscriptionRepository
    plans  PlanRepository
    pub    EventPublisher
    clock  func() time.Time
}

func NewStartSubscription(subs SubscriptionRepository, plans PlanRepository, pub EventPublisher, clock func() time.Time) *StartSubscription {
    return &StartSubscription{subs: subs, plans: plans, pub: pub, clock: clock}
}

func (u *StartSubscription) Execute(ctx context.Context, in StartSubscriptionInput) (*StartSubscriptionOutput, error) {
    plan, err := u.plans.GetByID(ctx, in.PlanID)
    if err != nil {
        return nil, err
    }

    sub, err := domain.NewSubscription(in.CustomerID, plan, u.clock())
    if err != nil {
        return nil, err
    }

    if err := u.subs.Save(ctx, sub); err != nil {
        return nil, err
    }

    _ = u.pub.Publish(ctx, domain.SubscriptionStartedEvent{
        ID:         sub.ID(),
        CustomerID: sub.CustomerID(),
        PlanID:     plan.ID,
    })

    return &StartSubscriptionOutput{Subscription: sub}, nil
}

The use case:

  1. Loads inputs from repositories
  2. Calls domain methods that enforce rules
  3. Persists results
  4. Publishes events
  5. Returns output

That’s the canonical shape. Three to ten lines of meaningful logic per use case.

Input/Output DTOs

Use case-specific structs at the boundary. Not domain types, not HTTP types — separate from both.

Why:

  • Domain types might have unexported fields; DTOs are exported
  • HTTP types have JSON tags; DTOs don’t
  • Input DTOs can be different from any single domain type (combined data from multiple sources)
  • Output DTOs let you return only what’s needed (no leaking internal state)

For tiny use cases, the DTO and the entity look almost identical. Still keep them separate — when business rules evolve, the entity changes; the DTO might not need to.

One use case per operation

Resist the urge to create SubscriptionService with 15 methods. Each operation gets its own struct + Execute.

Why:

  • Smaller dependencies per struct. CancelSubscription doesn’t need PlanRepository.
  • Tests are focused.
  • Renaming or removing one operation doesn’t touch the others.
  • “Show me the start-subscription flow” → open one file.

The cost: more files. Twenty use cases = twenty files. The benefit: each file is 30-50 lines and self-contained.

When to share logic

Common patterns to refactor when noticed:

Shared validation: A helper in domain, not in use case.

Shared transactional wrapper: A separate WithTransaction helper, or the Tx pattern from the repository post.

Shared notification logic: A domain service or a separate use case (NotifySubscriptionChanged) called by multiple use cases.

Don’t extract until you have 2-3 cases doing the same thing. Premature DRY is its own bug.

Testing

The payoff:

package usecase_test

import (
    "context"
    "testing"
    "time"

    "github.com/google/uuid"
    "github.com/stretchr/testify/require"
    "yourorg/billing/internal/domain"
    "yourorg/billing/internal/usecase"
)

type fakeSubRepo struct {
    saved []*domain.Subscription
}

func (f *fakeSubRepo) Save(ctx context.Context, s *domain.Subscription) error {
    f.saved = append(f.saved, s)
    return nil
}

func (f *fakeSubRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Subscription, error) {
    return nil, domain.ErrSubscriptionNotFound
}

type fakePlanRepo struct{}

func (f *fakePlanRepo) GetByID(ctx context.Context, id string) (domain.Plan, error) {
    return domain.Plan{ID: id, PriceCents: 1000, Currency: "USD", Interval: 30 * 24 * time.Hour}, nil
}

type fakePub struct {
    published []domain.Event
}

func (f *fakePub) Publish(ctx context.Context, e domain.Event) error {
    f.published = append(f.published, e)
    return nil
}

func TestStartSubscription(t *testing.T) {
    subs := &fakeSubRepo{}
    plans := &fakePlanRepo{}
    pub := &fakePub{}
    clock := func() time.Time { return time.Date(2022, 6, 15, 0, 0, 0, 0, time.UTC) }

    uc := usecase.NewStartSubscription(subs, plans, pub, clock)

    out, err := uc.Execute(context.Background(), usecase.StartSubscriptionInput{
        CustomerID: uuid.MustParse("00000000-0000-0000-0000-000000000001"),
        PlanID:     "pro_monthly",
    })

    require.NoError(t, err)
    require.NotNil(t, out.Subscription)
    require.Equal(t, domain.StatusActive, out.Subscription.Status())
    require.Len(t, subs.saved, 1)
    require.Len(t, pub.published, 1)
}

Fast. Hermetic. No DB, no HTTP server, no framework. Test runs in ~5ms.

The fakes are 30 lines total. No mock framework. They’re tailored to the test’s needs.

What use cases SHOULDN’T do

  • Parse JSON (HTTP adapter does that)
  • Format responses (HTTP adapter does that)
  • Validate request shapes (HTTP adapter does that — domain enforces business rules)
  • Render templates (HTTP/SSR adapter)
  • Auth checks (middleware or a separate use case)
  • Build SQL queries (repository implementation)
  • Read env vars or config files (passed in via DI)

Use cases coordinate. They don’t transport, render, or persist directly.

Common Pitfalls

Use cases that bypass domain. “Just update the field directly.” Use the domain method that enforces the rule.

One huge use case file. Split. One operation per file. start_subscription.go, cancel_subscription.go.

Returning raw maps from Execute. Typed DTOs.

Auth inside use cases. Belongs in middleware or a separate concern. Use case assumes auth has already happened.

Use cases calling other use cases. Sometimes legit (compound operations). Usually a smell — the second one should be a domain service or a use case helper.

No context.Context in Execute. Lost cancellation. Always first arg.

Wrapping Up

One operation per use case, input/output DTOs, hand-rolled fakes for tests. The result: 50-line files, 5ms tests, a clear map of what the service does. Friday: adapters — HTTP, gRPC, workers — wiring use cases to the outside world.