Backend Log

Refactoring SeatRush: Building a Clean Storage Boundary in Go

15 October 2026GoBackendArchitecturePostgreSQL

SeatRush worked. That was the problem.

Every route, slice, mutex and ID counter lived in one Go file. The API could create events, add seats and stop two people from holding the same seat. The tests passed. Then I tried to add PostgreSQL and found out that I had not built an API around storage. I had built HTTP handlers around three in-memory slices.

The database was not the hard part yet. Finding the boundary where a database could fit was.

The point where main.go stopped helping

The first version was useful because everything was visible at once. A route could see events, seats and reservations directly:

go
router.POST("/reservations", func(c *gin.Context) {
    // parse JSON
    // search the seats slice
    // lock the mutex
    // create a reservation
    // choose the HTTP status
})

That is a good way to learn a flow. It is a bad place to add a second storage system.

If I wrote SQL inside that handler, the handler would need to know HTTP, business rules, locking and database errors. Testing one rule would mean constructing an HTTP request or connecting to PostgreSQL. Replacing memory with a database would mean rewriting routes instead of swapping storage.

So I split the application before finishing the PostgreSQL migration.

The shape I ended up with

The project now has six small parts:

text
cmd/api/main.go
internal/app/router.go
internal/handler/
internal/service/
internal/repository/
internal/domain/

The request moves through them in one direction:

text
HTTP request
    -> handler
    -> service
    -> repository
    -> domain records

The important part is not the folders. The important part is what each folder is allowed to know.

Domain: the boring center

The domain package contains the plain records and status constants:

go
type Seat struct {
    ID      uint
    EventID uint
    Status  string
}

type Reservation struct {
    ID      uint
    UserID  uint
    EventID uint
    SeatID  uint
    Status  string
}

There is no Gin context and no SQL query here. A seat is a seat whether it came from a slice, PostgreSQL or a test.

This felt almost too simple to deserve a package. That simplicity is exactly why every other layer can depend on it.

Repository: where state changes

The memory repository owns the slices, counters and mutex. Holding a seat is one repository operation because the availability check and the update must stay atomic:

go
func (r *MemoryRepository) HoldSeat(
    userID uint,
    eventID uint,
    seatID uint,
) (bool, domain.Reservation) {
    r.mu.Lock()
    defer r.mu.Unlock()

    for i := range r.seats {
        if r.seats[i].ID != seatID || r.seats[i].EventID != eventID {
            continue
        }
        if r.seats[i].Status != domain.StatusAvailable {
            return true, domain.Reservation{}
        }

        r.seats[i].Status = domain.StatusHeld

        reservation := domain.Reservation{
            ID:      r.nextReservationID,
            UserID:  userID,
            EventID: eventID,
            SeatID:  seatID,
            Status:  domain.ReservationStatusPending,
        }
        r.nextReservationID++
        r.reservations = append(r.reservations, reservation)

        return true, reservation
    }

    return false, domain.Reservation{}
}

Earlier, the mutex belonged to the router state. Moving it into the repository made the rule clearer: whoever owns shared state also owns the lock protecting it.

The HTTP layer no longer needs to know how a seat becomes held.

Service: rules without HTTP

The service sits between handlers and storage. Right now it is deliberately thin, but it already owns rules such as refusing an empty event name and checking that an event exists before adding a seat:

go
func (s *Service) AddSeat(eventID uint) (domain.Seat, bool) {
    _, found := s.repo.GetEventByID(eventID)
    if !found {
        return domain.Seat{}, false
    }

    seat := s.repo.AddSeat(eventID)
    return seat, true
}

Gin does not appear here. Neither does SQL. The service speaks in domain values and outcomes.

That means the rule can be tested without deciding whether a missing event should become a 404, a CLI message or something else.

Handler: translate, do not decide

The handler reads an HTTP request, calls the service and translates the result into an HTTP response:

go
seatFound, reservation := h.service.ReserveSeat(
    input.UserID,
    input.EventID,
    input.SeatID,
)

if !seatFound {
    c.JSON(http.StatusNotFound, gin.H{"error": "Seat not found"})
    return
}
if reservation.ID == 0 {
    c.JSON(http.StatusConflict, gin.H{"error": "Seat unavailable"})
    return
}

c.JSON(http.StatusCreated, reservation)

The handler still decides status codes. It does not scan slices, increment IDs or take locks.

This was the clearest sign that the split was helping: I could read a handler and understand the whole HTTP contract without reading the storage implementation.

main became wiring

The executable now creates the concrete pieces and connects them:

go
repo := repository.NewMemoryRepository(events, seats, nil, 102, 3, 501)
svc := service.New(repo)
httpHandler := handler.New(svc)
router := app.NewRouter(httpHandler)

router.Run(":8080")

This is sometimes called the composition root. I mostly think of it as the only place that should know which implementation the application is using.

The router also became boring. It maps URLs to handler methods and nothing else. Boring wiring is good wiring.

The interface detail that finally clicked

I used to think an interface should live beside the type that implements it. In this project, the interfaces live beside the code that consumes them.

The service declares what it needs from a repository:

go
type Repository interface {
    HoldSeat(userID, eventID, seatID uint) (bool, domain.Reservation)
    GetEventByID(id uint) (domain.Event, bool)
    AddSeat(eventID uint) domain.Seat
}

The handler separately declares what it needs from a service.

Neither interface says how the work happens. The memory repository satisfies the first contract without importing the service package. Go checks the match implicitly when I pass it to service.New.

That made interfaces feel less like inheritance and more like a shopping list written by the caller.

Then PostgreSQL exposed the unfinished design

I added a PostgreSQL repository with real queries:

go
func (r *PostgresRepository) GetEventByID(id uint) (domain.Event, error) {
    var event domain.Event

    err := r.db.QueryRow(
        "SELECT id, name FROM events WHERE id = $1",
        id,
    ).Scan(&event.ID, &event.Name)

    return event, err
}

And then I could not simply pass it into service.New.

The memory contract returns a boolean for “found”. PostgreSQL naturally returns an error: sql.ErrNoRows for a missing record, plus connection, timeout and query errors that mean something completely different.

These two signatures are not the same:

go
GetEventByID(id uint) (domain.Event, bool)
GetEventByID(id uint) (domain.Event, error)

That mismatch is not Go being annoying. It is the design telling me that the current abstraction only describes memory storage.

I could hide every database failure behind false, but then a broken connection would look like “event not found”. The API might return 404 when PostgreSQL was actually down. That would make the interface compile and the system lie.

So the API still uses MemoryRepository today. PostgresRepository can create and list events, but the migration is not finished. I would rather leave that boundary visibly incomplete than force it with bad error handling.

Testing became more focused

The refactor split one large test surface into two useful ones.

The repository test sends 100 goroutines directly at HoldSeat and checks that exactly one reservation is created. It tests the concurrency rule without HTTP.

The router test builds the real service and handlers, sends requests through httptest, and checks status codes and JSON. It tests the API contract without opening a port.

sh
go test ./...
go test -race ./...

Both pass. The race test matters because moving code into folders does not make shared memory safe. The repository mutex still does that job.

What comes next

The next change is not “replace MemoryRepository with PostgresRepository”. First I need a repository contract that can represent both missing data and real failures honestly.

Then holding a seat must become a database transaction. A process mutex only protects one running server. Two API instances can each have their own mutex and still sell the same row twice.

The database has to become the referee.

My likely path is:

  1. Change repository methods to return errors that the service can classify.
  2. Finish the PostgreSQL methods for seats and reservations.
  3. Hold a seat with one transaction and a row lock or conditional update.
  4. Run the same concurrent reservation test against PostgreSQL.
  5. Only then switch the composition root from memory to Postgres.

What I am taking away

  • A working main.go is allowed to become uncomfortable. That discomfort tells you where the boundaries are.
  • Folders are not architecture. Clear responsibilities and dependency direction are.
  • Interfaces belong to the code that needs behavior, not automatically to the implementation.
  • A storage abstraction has to model failures, not just successful values.
  • If two implementations cannot satisfy the same interface honestly, the interface is probably the part that needs work.

The refactor did not add a feature a user can see. It gave the next feature somewhere correct to live.

That is less exciting than a new endpoint, but it is the first time SeatRush feels like an application I can keep extending instead of a file I am afraid to touch.