Implement or review the service layer for a Go service following architecture standards
Implement or review the service layer for $ARGUMENTS following the specs below exactly.
This document describes the top-level structure for a service in this project. Before building any layer, read this file. Then read the spec file for the specific layer you are implementing.
Every service follows this layout:
services/<name>/
├── main.go # Env validation, dependency wiring, server start
├── handler/
│ └── handler.go # Transport layer
├── service/
│ └── service.go # Business logic
├── database/
│ └── database.go # Data access
├── client/
│ └── client.go # Outbound clients to other services or APIs
├── types/
│ └── types.go # Local interfaces for every dependency
└── Dockerfile
Each directory is its own Go package named after the directory.
Implement layers in this order. Each layer depends on the one before it.
types/ — define all interfaces first so every other layer has a contract to depend ondatabase/ — implement the data access layer against the Database interfaceclient/ — implement any outbound clients against their interfacesservice/ — implement business logic using only the interfaces from types/handler/ — implement the transport layer using only the Service interface from types/main.go — wire all concrete implementations together and start the serverDepend on interfaces, not implementations. Every layer holds its dependencies as interfaces from types/. Nothing is imported directly from sibling packages except in main.go.
Inject all dependencies through constructors. All dependencies are passed into New* functions. Nothing is instantiated inside the service or handler.
One responsibility per layer. Transport logic does not touch the database. Business logic does not build queries. Queries do not make outbound calls.
Crash on bad config, log on recoverable errors. Missing environment variables are fatal at startup. Cache failures and best-effort side effects are logged and swallowed. Anything that prevents a correct response is returned as an error.
Shared infrastructure lives in services/common. Reusable clients, interfaces, and domain models are defined there and imported. Never copy infrastructure code into a service.
types/types.goDefine the interfaces that every other layer in the service depends on. This file is the contract layer — it describes what each dependency must provide without specifying how.
Service, Database, SomeClient, etc.Service interface must exactly match the public methods implemented on the service struct — the handler depends on itservices/common and are imported here as neededhandler, service, database, client) — only from services/common and standard librariespackage types
import (
"context"
commontypes "github.com/kabradshaw1/story/services/common"
"github.com/kabradshaw1/story/services/common/genproto/<name>"
)
type Service interface {
MethodOne(ctx context.Context, req *proto.Request) (*proto.Response, error)
MethodTwo(ctx context.Context, req *proto.Request) (*proto.Response, error)
}
type Database interface {
FindRecord(ctx context.Context, id int) (*commontypes.SomeModel, error)
SaveRecord(ctx context.Context, record commontypes.SomeModel) error
}
type SomeClient interface {
Notify(ctx context.Context, payload SomePayload) error
}
Service interface matches the service struct's public methods exactlyservices/common, not redefineddatabase/database.goExecute all data access operations. This is the only layer that communicates with the database.
Connect function (or equivalent) accepts a connection string, establishes the connection, and returns a typed *DB struct; it calls log.Fatal on failurecontext.Context as its first argument and passes it through to the underlying querypackage database
type DB struct {
client *SomeClient
}
func Connect(connectionString string) *DB {
// connect, fatal on error
return &DB{client: client}
}
func (d *DB) FindRecord(ctx context.Context, id int) (*commontypes.SomeModel, error) {
// query using ctx
}
func (d *DB) SaveRecord(ctx context.Context, record commontypes.SomeModel) error {
// insert using ctx
}
For any mutation scoped to a user, always verify ownership before modifying data:
record.User == requestingUser — return a descriptive error if notAfter an update query, do not re-fetch the record to return it. Instead, update the already-fetched struct in memory:
// fetch → verify → update → return modified struct
record.Field = newValue
return &record, nil
fmt.Errorf("failed to find record: %w", err)Connect accepts a connection string and calls log.Fatal on failurecontext.Context as its first parameterclient/client.goHandle all outbound calls to external services or third-party APIs. This is the only layer that makes outbound network requests.
New* constructor initializes and returns the client structcontext.Context as its first argument and passes it to the underlying callpackage client
type SomeClient struct {
// underlying transport (http.Client, grpc.ClientConn, etc.)
}
func New() *SomeClient {
return &SomeClient{
// initialize transport
}
}
func (c *SomeClient) Notify(ctx context.Context, payload SomePayload) error {
if payload.RequiredField == "" {
return fmt.Errorf("RequiredField is required")
}
// construct and send request using ctx
// check response and return error on failure
return nil
}
New* constructor initializes the transportcontext.Contextservice/service.goImplement all business logic. Orchestrate calls to the database, cache, and outbound clients to fulfill a request.
types/ — never concrete implementationsNew* constructor accepts every dependency as an argument — nothing is instantiated insidetypes.Database methods onlypackage service
type Service struct {
db types.Database
cache commontypes.Cache
client types.SomeClient
}
func NewService(db types.Database, cache commontypes.Cache, client types.SomeClient) *Service {
return &Service{db: db, cache: cache, client: client}
}
Use a read-through cache on any method that reads data. Keys must use : as a separator between all components to prevent collisions.
cacheKey := "resource:" + userID + ":" + entityType
Order of operations:
Cache errors (get, set, unmarshal, marshal) are always logged and swallowed — a cache failure must never prevent a valid response from being returned.
cacheData, err := s.cache.Get(ctx, cacheKey)
if err == nil && cacheData != "" {
if err := json.Unmarshal([]byte(cacheData), &response); err != nil {
log.Printf("<service>/service: Method: failed to unmarshal cache: %v", err)
} else {
return &response, nil
}
}
// ... query db, build response ...
serializedData, err := json.Marshal(&response)
if err != nil {
log.Printf("<service>/service: Method: failed to marshal for cache: %v", err)
} else {
if err := s.cache.Set(ctx, cacheKey, string(serializedData), 10*time.Minute); err != nil {
log.Printf("<service>/service: Method: failed to set cache: %v", err)
}
}
fmt.Errorf("description: %w", err)All log messages must identify the service and method:
"<service>/service: MethodName: description of what failed"
types.* interfaces and commontypes.* interfaces — no concrete typesNew* constructor accepts all dependencies as arguments: separators between all variable componentsfmt.Errorf<service>/service: Method: description format