DEV Community

Cover image for Your Go 'Service' Layer Is Just a Transaction Script. That's Not a Bug
Gabriel Anhaia
Gabriel Anhaia

Posted on

Your Go 'Service' Layer Is Just a Transaction Script. That's Not a Bug


You open order_service.go. There is a method called PlaceOrder. It reads a customer, validates a coupon, sums line items, writes a row, publishes an event. Thirty-something lines, top to bottom, no branching that matters. You stare at it and a voice in your head says this isn't real domain code. Somebody on the team will eventually file a ticket called "extract Order aggregate" and put it in the backlog where it will live forever.

The voice is wrong. The method is fine. It is a Transaction Script (a real, named pattern from Martin Fowler's Patterns of Enterprise Application Architecture), and pretending it should be something fancier is how you end up with five files of ceremony around a use case that boils down to "insert a row and send an email."

This post is the permission slip. Use Transaction Script when the logic is thin. Use Domain Model when it gets heavy. Hexagonal lets the two coexist in the same service. Pick per use case.

What Fowler actually wrote

PoEAA describes Transaction Script as a procedure that "takes the input from the presentation, processes it with validations and calculations, stores data in the database, and invokes any operations from other systems" (Fowler's catalog entry). One linear function per business action. The book frames it as a starting pattern: easy to understand, easy to teach, and the right call when the domain logic is simple. It also stops scaling once invariants compound, because shared rules get duplicated across scripts and the duplication is where bugs breed.

Domain Model is the answer when the logic gets thick. Entities and aggregates own their invariants. Methods on the aggregate are the only path to mutating state. The model gets harder to write but easier to extend, because the rules live in one place.

Fowler does not say one is better. He says they have a crossover point, and the cost of using the wrong one on either side of that line is real.

The Go service method, honestly

The procedural version of PlaceOrder. One function. Reads, validates, computes, writes, publishes. No aggregate. No factory. No domain.Order.New(...) ceremony.

package ordering

type PlaceOrderInput struct {
    CustomerID string
    Items      []LineItem
    CouponCode string
}

type PlaceOrderOutput struct {
    OrderID string
    Total   Money
}

type orders struct {
    customers customerFinder
    coupons   couponFinder
    repo      orderWriter
    events    eventPublisher
    clock     func() time.Time
    ids       func() string
}

func (s *orders) Place(
    ctx context.Context,
    in PlaceOrderInput,
) (*PlaceOrderOutput, error) {
    cust, err := s.customers.Get(ctx, in.CustomerID)
    if err != nil {
        return nil, fmt.Errorf("place order: %w", err)
    }
    if !cust.Active {
        return nil, ErrCustomerInactive
    }
    if len(in.Items) == 0 {
        return nil, ErrEmptyOrder
    }

    discount := Money{}
    if in.CouponCode != "" {
        c, err := s.coupons.Get(ctx, in.CouponCode)
        if err != nil {
            return nil, fmt.Errorf("place order: %w", err)
        }
        discount = c.Amount
    }

    total := sumItems(in.Items).Sub(discount)
    if total.IsNegative() {
        total = Money{}
    }

    o := Order{
        ID:         s.ids(),
        CustomerID: cust.ID,
        Items:      in.Items,
        Total:      total,
        PlacedAt:   s.clock(),
    }
    if err := s.repo.Save(ctx, o); err != nil {
        return nil, fmt.Errorf("place order: %w", err)
    }
    s.events.Publish(ctx, OrderPlaced{OrderID: o.ID})

    return &PlaceOrderOutput{OrderID: o.ID, Total: o.Total}, nil
}
Enter fullscreen mode Exit fullscreen mode

Around thirty lines of real logic. The struct Order is a record with no methods. The interfaces are consumer-defined ports. The validations live where they are needed. A reader gets the entire flow in one screen and stops scrolling because they have what they came for.

Two invariants worth naming: the customer must be active, the order must have items. A floor of zero on the total. Past that, the rules are arithmetic. There is nothing here that wants to be a method on a self-validating aggregate, because the aggregate would not stop you from doing anything the procedure already prevents.

This is what most service methods in most Go codebases actually look like. Calling them Transaction Scripts is not an insult. It is the right name for the shape.

When the script earns a domain model

Now imagine the same use case six months later. Marketing wants tiered loyalty discounts that interact with coupons. Finance wants partial refunds that change the order's status, but only from paid, never from shipped. Ops wants split fulfilment with each line item tracked separately, and the total cannot drop below the already-captured payment. The number of invariants stops being two and becomes nine. They start to interact: a refund on a line item changes the loyalty tier, which changes the next coupon's eligibility, which changes the total floor.

You can keep this in a Transaction Script. You will end up with three more if blocks per release. The rules will be spread across Place, Refund, Ship, Cancel, and ApplyCoupon. The duplication will catch you the first time someone changes the loyalty rule in four of the five and forgets the fifth.

At the Domain Model crossover, the same use case written with an Order aggregate that owns its state transitions looks like this.

package ordering

type Order struct {
    id         OrderID
    customer   CustomerID
    items      []LineItem
    discount   Money
    status     Status
    placedAt   time.Time
}

func NewOrder(
    id OrderID,
    cust Customer,
    items []LineItem,
    coupon *Coupon,
    now time.Time,
) (*Order, error) {
    if !cust.Active {
        return nil, ErrCustomerInactive
    }
    if len(items) == 0 {
        return nil, ErrEmptyOrder
    }
    o := &Order{
        id:       id,
        customer: cust.ID,
        items:    items,
        status:   StatusPlaced,
        placedAt: now,
    }
    if coupon != nil {
        if err := o.applyCoupon(*coupon); err != nil {
            return nil, err
        }
    }
    return o, nil
}

func (o *Order) applyCoupon(c Coupon) error {
    if o.status != StatusPlaced {
        return ErrCouponAfterPlacement
    }
    if c.MinTotal.GreaterThan(o.subtotal()) {
        return ErrCouponBelowMin
    }
    o.discount = c.Amount
    return nil
}

func (o *Order) Refund(line LineItemID, captured Money) error {
    if o.status != StatusPaid {
        return ErrRefundWrongStatus
    }
    item, ok := o.findItem(line)
    if !ok {
        return ErrLineItemUnknown
    }
    if o.Total().Sub(item.Price).LessThan(captured) {
        return ErrRefundBelowCapture
    }
    o.removeItem(line)
    return nil
}

func (o *Order) Total() Money {
    t := o.subtotal().Sub(o.discount)
    if t.IsNegative() {
        return Money{}
    }
    return t
}
Enter fullscreen mode Exit fullscreen mode

Now the rules live on the type that holds the state. applyCoupon cannot be called on a shipped order because the method checks the status. Refund cannot drop the total below the captured payment because the invariant is one method away from the field it protects. The service method becomes a thin orchestrator: load the aggregate, call methods, save it back, publish events. Eighty-ish lines of model code, but the rules stop being copy-pasted across five scripts.

You did not need this on day one. You needed it the day the third invariant landed.

The decision rule, written down

Use Transaction Script when:

  • Fewer than three invariants on the entity's state.
  • Logic is linear: validate, compute, write, publish.
  • The same rules are not duplicated across multiple use cases.
  • The domain language is thin — the use case maps to one verb.

Switch to Domain Model when:

  • Invariants compound and interact.
  • State transitions matter — placedpaidshipped is a real machine, not a string.
  • The same rule appears in three or more use cases and you have already copy-pasted it once.
  • A reviewer cannot tell from reading two methods whether they enforce the same thing.

The trap on each side is symmetric. Transaction Script breaks down silently — no test fails, the rules just drift. Domain Model overshoots loudly — every change requires touching three layers, and the team starts routing around the model.

The first failure is a year of slow bug accretion. The second is a backlog full of "simplify the aggregate" tickets that never get done.

How hexagonal lets them live together

PoEAA was written before consumer-defined interfaces existed. In Go, your service package declares the ports it needs: customerFinder, orderWriter, eventPublisher. The rest of the system meets them. That is the same plumbing whether the inside of Place is a thirty-line procedure or a call to Order.Refund(...).

You can have one package per bounded context, and inside that package, some use cases stay scripts forever and some grow into models. signup is probably a script. billing probably is not. inventory_adjustments is a script until the day it isn't, and you will know.

The honest version of "should I write an aggregate" is: write the script first. Notice the second time you copy-paste a rule. Promote to a model the third time. The cost of the move is not zero, but it is far less than the cost of building the model speculatively for the script that never grew up.

What changes in your head

You stop apologizing for service methods that are linear. Anaemic is the wrong word for a Transaction Script. It belongs on a half-built Domain Model. You stop adding domain/, application/, infrastructure/ folders before you have logic that deserves the separation, and you start naming things by the use case they serve.

The next time someone on the team says "this should really be an aggregate," ask them to name the third invariant. If they can, you have a model on your hands. If they cannot, you have a Transaction Script, and Fowler's book is on your side.


If this was useful

The script-versus-model decision is one of those calls that does not show up in most Go architecture writing, because most posts assume you are already past it. Hexagonal Architecture in Go covers it the way it shows up in real services — same package, mixed use cases, ports that do not care which pattern lives behind them. The book walks through when to promote a script and what the migration looks like in code, plus the boundary work that keeps it clean. PoEAA is still the canonical reference for the patterns themselves; this is how they land in idiomatic Go.

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

Top comments (0)