DEV Community

Cover image for Application Service vs Domain Service in Go: Two Layers, Different Jobs
Gabriel Anhaia
Gabriel Anhaia

Posted on

Application Service vs Domain Service in Go: Two Layers, Different Jobs


You're reviewing a Go PR that adds money transfers between two accounts. The function lives in application/transfer_service.go. It opens a transaction, loads the source account, calls src.Withdraw(amount), loads the destination, calls dst.Deposit(amount), saves both, commits. Three hundred lines down, the same file calls into the database to check whether a fraud rule fired in the last 24 hours, computes a new compliance limit, and writes that back.

Half of that file belongs in a different package.

The use-case orchestration (load, call, save, publish) is an application service. The fraud-and-limit logic that spans two accounts and has nothing to do with I/O is a domain service. Both names come from DDD, originally laid out by Eric Evans in Domain-Driven Design (2003). Both end up in folders called service/ in real codebases. They are not the same thing. Test the limit calculation without spinning Postgres and the conflation will cost you.

This post separates the two. One package each, side-by-side code, and the rule that places anything new in the right one.

What an application service actually does

An application service is the thin layer that runs a use case. It lives in application/. It depends on ports (repositories, event publishers, clocks), opens transactions, and does not contain business rules.

Read that last sentence twice. The application service knows the sequence of a use case. It does not know the meaning of one. A TransferMoney application service knows: load source, load destination, call domain methods, persist atomically, publish an event. It does not know what makes a transfer valid. That knowledge belongs to the Account aggregate, or to a domain service when the rule spans two of them.

package application

import (
    "context"
    "fmt"
    "time"

    "example.com/bank/internal/domain"
    "example.com/bank/internal/port"
)

type TransferService struct {
    accounts port.AccountRepository
    events   port.EventPublisher
    tx       port.TxRunner
    clock    port.Clock
}

type TransferInput struct {
    FromID domain.AccountID
    ToID   domain.AccountID
    Amount domain.Money
    Memo   string
}

func (s *TransferService) Transfer(
    ctx context.Context,
    in TransferInput,
) error {
    return s.tx.Run(ctx, func(ctx context.Context) error {
        src, err := s.accounts.Get(ctx, in.FromID)
        if err != nil {
            return fmt.Errorf("load source: %w", err)
        }
        dst, err := s.accounts.Get(ctx, in.ToID)
        if err != nil {
            return fmt.Errorf("load dest: %w", err)
        }

        evt, err := domain.Transfer(
            src, dst, in.Amount, in.Memo, s.clock.Now(),
        )
        if err != nil {
            return err
        }

        if err := s.accounts.Save(ctx, src); err != nil {
            return fmt.Errorf("save source: %w", err)
        }
        if err := s.accounts.Save(ctx, dst); err != nil {
            return fmt.Errorf("save dest: %w", err)
        }
        return s.events.Publish(ctx, evt)
    })
}
Enter fullscreen mode Exit fullscreen mode

Look at what is and is not here. The method orchestrates the use case using the ports it owns, opens one transaction, and calls a single function (domain.Transfer) which is the domain service that holds the rule. There is no if amount.IsNegative() here, no balance check, no memo-length validation. Those are domain concerns and they live where the domain lives.

If you find yourself writing an if block that encodes a business rule inside an application service, the rule is in the wrong package. Push it down.

What a domain service actually does

A domain service holds invariant-bearing logic that does not naturally fit on a single aggregate. The classic case is exactly the one above: a transfer changes the state of two Account aggregates and has rules that cross both. You cannot put Transfer on Account without picking a side, and picking a side leaves the rule split between source and destination.

Domain services live in domain/ with no I/O, take and return aggregate values, and stay as pure functions when possible.

package domain

import (
    "errors"
    "time"
)

var (
    ErrSameAccount     = errors.New("source and dest are equal")
    ErrCurrencyMismatch = errors.New("currency mismatch")
    ErrTransferZero    = errors.New("transfer must be positive")
)

type TransferCompleted struct {
    From      AccountID
    To        AccountID
    Amount    Money
    Memo      string
    CompletedAt time.Time
}

func Transfer(
    src *Account,
    dst *Account,
    amount Money,
    memo string,
    now time.Time,
) (TransferCompleted, error) {
    if src.ID() == dst.ID() {
        return TransferCompleted{}, ErrSameAccount
    }
    if amount.IsZeroOrNegative() {
        return TransferCompleted{}, ErrTransferZero
    }
    if !src.Currency().Equals(dst.Currency()) {
        return TransferCompleted{}, ErrCurrencyMismatch
    }

    if err := src.Withdraw(amount, memo, now); err != nil {
        return TransferCompleted{}, err
    }
    if err := dst.Deposit(amount, memo, now); err != nil {
        // src.Withdraw is reversed by the caller's tx
        return TransferCompleted{}, err
    }

    return TransferCompleted{
        From: src.ID(), To: dst.ID(),
        Amount: amount, Memo: memo,
        CompletedAt: now,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Three rules live in this function: same-account is a no-op, zero or negative is invalid, currency must match. None of them belong to one account on its own; they only mean something across the pair. So they live in a domain service that takes both.

The function then delegates to each aggregate for the rules that are single-aggregate concerns: src.Withdraw enforces overdraft policy, dst.Deposit enforces credit limits and frozen-account checks. Each aggregate owns its own invariants, and the domain service owns the relationship between them.

Notice the things that are not in this file: no ctx, no *sql.DB, no event publisher. A unit test calls Transfer(src, dst, amount, "rent", t) and checks the returned event. There is nothing to mock.

The line you don't cross

The clean rule is import-direction:

  • application/ imports domain/ and port/. It calls domain methods and domain services. It does not implement them.
  • domain/ imports nothing from the project. Standard library only. It does not know port exists, let alone application.
  • port/ imports domain/ to name types in interface signatures.

If you ever find a file in domain/ that imports database/sql, net/http, context.Context from infrastructure, or anything from application/, the layers have leaked. A one-line CI check catches it: go list -f '{{.ImportPath}}: {{.Imports}}' ./internal/domain/... | grep -E 'database/sql|net/http|application' should return nothing. Fail the build when it does.

Use context.Context in domain code only when a domain method genuinely needs cancellation (rare). Most pure domain services do not. They take values, return values, and let the application layer handle deadlines around them.

A worked side-by-side: where each rule belongs

Here is the same Account aggregate that the two services above sit either side of.

package domain

import (
    "errors"
    "time"
)

type AccountID string

type Account struct {
    id        AccountID
    balance   Money
    currency  Currency
    frozen    bool
    movements []Movement
}

type Movement struct {
    Amount Money
    Memo   string
    At     time.Time
}

var (
    ErrFrozen           = errors.New("account is frozen")
    ErrInsufficientFunds = errors.New("insufficient funds")
)

func (a *Account) ID() AccountID    { return a.id }
func (a *Account) Currency() Currency { return a.currency }

func (a *Account) Withdraw(
    amount Money, memo string, now time.Time,
) error {
    if a.frozen {
        return ErrFrozen
    }
    if a.balance.LessThan(amount) {
        return ErrInsufficientFunds
    }
    a.balance = a.balance.Sub(amount)
    a.movements = append(a.movements, Movement{
        Amount: amount.Negate(), Memo: memo, At: now,
    })
    return nil
}

func (a *Account) Deposit(
    amount Money, memo string, now time.Time,
) error {
    if a.frozen {
        return ErrFrozen
    }
    a.balance = a.balance.Add(amount)
    a.movements = append(a.movements, Movement{
        Amount: amount, Memo: memo, At: now,
    })
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Withdraw and Deposit are aggregate methods. They protect single-aggregate invariants: don't move money on a frozen account, don't withdraw more than the balance. Those rules belong to one Account and travel with it.

Transfer is a domain service because the same-account check, the currency match, and the zero-amount rule are about the transaction across both accounts, never about one in isolation. Putting any of them on Account.Withdraw makes withdraw lie about its job.

TransferService.Transfer is an application service because opening a database transaction, loading two aggregates, saving them, and publishing an event are use-case orchestration. None of those steps know what a valid transfer is; they know what a valid use case execution looks like.

Three layers, three jobs. Read each in isolation:

File Knows about Tested with
domain/account.go balance, frozen state, movements unit tests, no fakes
domain/transfer.go two accounts, same-currency rule unit tests, no fakes
application/transfer_service.go repository, tx, events, clock unit tests with port fakes

The trap: domain services that secretly want a database

The mistake that breaks this layout shows up when someone writes a domain service that "needs" a repository. "I'm computing the new credit limit, but I need the customer's last 90 days of transfers, so I'll just take a TransferRepository here."

Stop. A domain service that depends on a port is an application service in the wrong package. There are two clean fixes.

The first is to load the data in the application layer and pass it in:

// application/limit_service.go
history, err := s.transfers.Last90Days(ctx, customerID)
if err != nil { return err }

newLimit, err := domain.RecomputeLimit(account, history)
if err != nil { return err }
Enter fullscreen mode Exit fullscreen mode

domain.RecomputeLimit is now a pure function. It takes the inputs it needs and returns the answer. Tests do not need a mock repository; they pass a []Transfer slice.

The second fix, when the inputs are too heavy to load greedily, is a domain query abstraction owned by the domain. The domain declares what it needs (type TransferHistory interface { Last90Days(...) []Transfer }), the application layer provides it. Reach for this rarely. In most cases, loading the data in the application layer and passing values is the simpler move.

Either way, the line holds: I/O lives in adapters, orchestration lives in application, rules live in domain.

How this lands in a hex Go service

The package layout is the same three-package shape you've seen before, with application/ slotting in next to domain/:

bank/
├── internal/
│   ├── domain/        # account.go, transfer.go (domain service)
│   ├── application/   # transfer_service.go (use case)
│   ├── port/          # AccountRepository, EventPublisher, Clock
│   └── adapter/       # postgres, kafka, http handler
└── main.go
Enter fullscreen mode Exit fullscreen mode

adapter/http/transfer_handler.go parses JSON, builds a TransferInput, and calls application.TransferService.Transfer. It never touches domain.Transfer directly, because that would skip the use case orchestration. The handler is the inbound adapter, the application service is the use case, the domain service is the rule, and the aggregate owns its own state.

When the next feature lands (say, internal transfers between business sub-accounts with their own fee rule), you know exactly where each piece goes. Sub-account-fee logic that spans two accounts goes in domain/. Loading them, opening a transaction, saving them, charging the fee account, and publishing an event goes in application/. The handler stays one screen long.

The two-services question collapses into a single check: is this a rule, or is this a sequence? Rules go down into the domain. Sequences go up into the application layer. Once your team agrees on that distinction, code reviews on this stuff stop taking 47 comments to resolve.


If this was useful

Application service versus domain service is a distinction that often gets less coverage than it deserves, and many Go layouts in the wild end up with service/ directories full of mixed concerns. Hexagonal Architecture in Go walks the full layout: application services, domain services, ports, adapters, and the small number of CI checks that keep the import graph honest. The Complete Guide to Go Programming is the companion when interface design and package boundaries are still the part that's slowing you down.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)