DEV Community

Cover image for Thin Application Services in Go: Orchestration Without Business Logic
Gabriel Anhaia
Gabriel Anhaia

Posted on

Thin Application Services in Go: Orchestration Without Business Logic


You open order_service.go to fix a small bug. The function
that places an order is 180 lines. It validates the cart,
computes the discount, checks the credit limit, decides whether
the order ships free, opens a transaction, calls the payment
gateway, writes three tables, sends an email, and pushes a
metric. The rule you came to fix — "orders over $500 get free
shipping" — is buried on line 94, next to a SQL string.

You change the threshold. A test breaks. To run that test you
need a database, a fake payment gateway, an SMTP stub, and a
metrics sink. The shipping rule has nothing to do with any of
them, but you cannot test it alone, because it does not live
alone.

That is the smell. The application service grew business logic.
The fix is a split: the service coordinates ports, the domain
holds the rules. Done right, the service stays small enough to
read in one screen.

The two jobs that get confused

There are two different jobs in that 180-line function, and they
have different reasons to change.

Orchestration is the sequence of steps that fulfils a use
case. Load the order, ask the domain a question, save the
result, publish an event. It talks to the outside: databases,
queues, clocks, payment gateways. It changes when the workflow
changes — a new step, a different store, an added notification.

Business logic is the rules. When does an order qualify for
free shipping. What makes a discount valid. Whether a status
transition is legal. It talks to nothing outside itself. It
changes when the business changes.

When both live in the same function, every workflow edit risks a
rule, and every rule edit drags in a database. Separate them and
each side gets a single reason to change.

The rules go in the domain

Start with the rule that was buried on line 94. It belongs on
the domain type, where it can be tested with no infrastructure
at all.

// domain/order.go
package domain

import "time"

type Money int64 // cents

type Order struct {
    ID        string
    Customer  string
    Lines     []Line
    Status    Status
    CreatedAt time.Time
}

type Line struct {
    SKU      string
    Quantity int
    Price    Money
}

func (o Order) Subtotal() Money {
    var sum Money
    for _, l := range o.Lines {
        sum += l.Price * Money(l.Quantity)
    }
    return sum
}

func (o Order) QualifiesForFreeShipping() bool {
    return o.Subtotal() >= 50000 // $500.00
}
Enter fullscreen mode Exit fullscreen mode

No database, no context, no interface. You test the shipping
rule by building an Order in memory and calling a method.

func TestFreeShipping(t *testing.T) {
    o := domain.Order{Lines: []domain.Line{
        {SKU: "A", Quantity: 1, Price: 60000},
    }}
    if !o.QualifiesForFreeShipping() {
        t.Fatal("expected free shipping over $500")
    }
}
Enter fullscreen mode Exit fullscreen mode

That test runs in microseconds and never flakes. The rule lives
where it can be exercised in isolation. When the threshold
changes, you edit one method and one test.

State transitions belong here too. A domain method enforces the
legal moves so no caller can put an order in an impossible
state.

// domain/order.go (import "fmt" added to the file)
type Status string

const (
    StatusPending   Status = "pending"
    StatusConfirmed Status = "confirmed"
    StatusShipped   Status = "shipped"
)

func (o *Order) Confirm() error {
    if o.Status != StatusPending {
        return fmt.Errorf(
            "cannot confirm order in status %q", o.Status,
        )
    }
    o.Status = StatusConfirmed
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The rule "you can only confirm a pending order" is now
unbreakable from outside the domain. The service never checks
the status by hand.

The ports describe what the service needs

The application service does not import a database driver or an
HTTP client. It depends on interfaces — ports — that say what it
needs in domain terms.

// port/order.go
package port

import (
    "context"

    "yourapp/domain"
)

type OrderRepository interface {
    Save(ctx context.Context, o domain.Order) error
}

type PaymentGateway interface {
    Charge(
        ctx context.Context, customer string, amount domain.Money,
    ) error
}

type EventPublisher interface {
    Publish(ctx context.Context, name, aggregateID string) error
}
Enter fullscreen mode Exit fullscreen mode

Three small interfaces, each named for what the service wants,
not for the technology behind it. The Postgres adapter, the
Stripe adapter, and the Kafka adapter implement them. The
service never sees those names.

The service coordinates, and stays thin

Now the use case. The application service holds the ports, runs
the steps in order, and asks the domain for every decision. It
makes no decision of its own.

// app/place_order.go
package app

import (
    "context"

    "yourapp/domain"
    "yourapp/port"
)

type PlaceOrder struct {
    orders   port.OrderRepository
    payments port.PaymentGateway
    events   port.EventPublisher
}

func NewPlaceOrder(
    o port.OrderRepository,
    p port.PaymentGateway,
    e port.EventPublisher,
) *PlaceOrder {
    return &PlaceOrder{orders: o, payments: p, events: e}
}

func (s *PlaceOrder) Handle(
    ctx context.Context, o domain.Order,
) error {
    if err := o.Confirm(); err != nil {
        return err
    }
    amount := o.Subtotal()
    if !o.QualifiesForFreeShipping() {
        amount += shippingFee
    }
    if err := s.payments.Charge(
        ctx, o.Customer, amount,
    ); err != nil {
        return err
    }
    if err := s.orders.Save(ctx, o); err != nil {
        return err
    }
    return s.events.Publish(ctx, "OrderPlaced", o.ID)
}

const shippingFee = domain.Money(999)
Enter fullscreen mode Exit fullscreen mode

Read the Handle body top to bottom. Every line is either a
call to a port or a call to a domain method. There is no if
that encodes a rule, no arithmetic that decides anything, no SQL.
The free-shipping rule is o.QualifiesForFreeShipping(). The
status rule is o.Confirm(). The total is o.Subtotal(). The
service knows the order of the steps and nothing about why each
step is what it is.

It fits in well under 30 lines. That is the target: if a use
case method does not fit on a screen, a rule has probably leaked
into it.

How to tell which side a line belongs to

When you add a line and are not sure where it goes, ask one
question: does it need the outside world to run?

If you can decide it from the data already in hand — a
comparison, a sum, a status check, a validation — it is a rule.
Push it onto a domain method.

If it reaches out — reads a row, charges a card, publishes a
message, reads the clock — it is orchestration. It stays in the
service, behind a port.

The clock is the one people miss. "An order expires after 24
hours" has two parts. Whether a given timestamp is expired is a
rule the domain can answer if you pass it the current time. Where
that current time comes from is orchestration.

// domain/order.go (same "time" import as above)
func (o Order) IsExpired(now time.Time) bool {
    return now.Sub(o.CreatedAt) > 24*time.Hour
}
Enter fullscreen mode Exit fullscreen mode

The domain takes now as a parameter instead of calling
time.Now(). The service reads the clock through a port and
passes the value in. The rule stays testable without freezing
time.

What the split buys you

The shipping-rule test from the top now runs with no database,
no payment stub, and no SMTP server, because the rule no longer
lives next to them. You build an Order and call a method.

The service gets tested too, but for a different thing: that it
calls the steps in the right order and stops on the first error.
You feed it fake ports — in-memory implementations of the three
interfaces — and assert the sequence. You are not re-testing the
rules there; the domain tests already cover those.

When the workflow changes — add fraud screening before the
charge — you edit the service and leave every domain rule
untouched. When a rule changes — free shipping moves to $300 —
you edit one domain method and its test, and the service does not
move at all. Each side has exactly one reason to change, which is
the whole point of keeping them apart.

The 180-line function did the same work. It just did it where you
could not see the seams, and could not test either half without
the other. The thin service is the same logic with the seams
made visible and the rules pulled into a place that needs nothing
to run.


If this was useful

The split between a thin application service and a domain that
owns its rules is the load-bearing idea in Hexagonal Architecture in Go.
The book works through the full layout — domain, ports, adapters,
and the application services that wire them — and the patterns
for keeping the service thin as the workflow grows. The Complete
Guide to Go Programming
covers the language pieces the adapters
lean on, from interfaces to context to error wrapping.

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

Top comments (0)