Learn / Go Service Engineering
Interfaces at Boundaries
Use interfaces where they describe ownership seams, not everywhere by habit.
Course: Go Service Engineering. Level: Intermediate. Topic: Reliable services.
Stage: intermediate - Intermediate package boundaries - Interfaces at service boundaries. Use interfaces where ownership changes hands: external APIs, persistence, queues, and tests.
Outcomes
- Keep interfaces small.
- Mock only external boundaries.
- Avoid package cycles.
- Define interfaces around behavior needed by consumers.
- Use composition and embedding to reuse behavior deliberately.
- Avoid over-abstracting concrete code too early.
- Write table-driven tests and subtests.
- Use fakes and interfaces to test behavior without fragile mocks.
- Add benchmarks and fuzz tests where they provide signal.
- Design packages around cohesive behavior.
- Use modules for versioned dependency management.
- Apply internal packages and command directories appropriately.
Concepts
- consumer-owned interface
- adapter
- package boundary
- test double
- Interfaces and Composition
- Interfaces are satisfied implicitly
- Embedding is composition, not inheritance
- Go Programming foundations
- Guided practice
- Testing
- Table tests scale examples
- Fakes over fragile mocks
- Packages and Project Structure
- Packages are API boundaries
- Modules define dependency roots
Concept flow
Show how interfaces at service boundaries moves from trigger to implementation outcome in Go Services.
- Handler
- Service interface
- Adapter
- External API
Session flow
- Model consumer-owned interface (concept, 7 min) — Name the decisions behind consumer-owned interface before writing code.
- Keep interfaces small.
- Explain where consumer-owned interface belongs in ticket processing platform.
- Build the vertical slice (walkthrough, 13 min) — Implement the smallest useful slice in orders/service.go.
- Mock only external boundaries.
- Connect adapter to a working example.
- Verify and harden (exercise, 9 min) — Add a fake for a boundary test.
- Avoid package cycles.
- Record one risk or follow-up before moving on.
- Small Interfaces and Embedding: Interfaces are satisfied implicitly (concept, 43 min) — A Go type satisfies an interface by implementing its methods; no declaration is required. This makes interfaces most powerful when they are small and owned by the package that consumes behavior. The classic standard library examples are io.Reader and io.Writer.
- Retained source example: Consumer-owned interface
type EmailSender interface {
SendEmail(to, subject, body string) error
}
func Welcome(sender EmailSender, email string) error {
return sender.SendEmail(email, "Welcome", "Thanks for joining.")
}
The consumer declares only the method it actually needs.
- Small Interfaces and Embedding: Embedding is composition, not inheritance (walkthrough, 43 min) — Embedding promotes fields or methods from an embedded type, which can reduce forwarding boilerplate. It does not create an inheritance hierarchy. Prefer explicit fields when promotion would make ownership or API behavior surprising.
- Retained source example: Embedding for shared behavior
type Logger struct{}
func (Logger) Info(msg string) {
fmt.Println("INFO", msg)
}
type Worker struct {
Logger
Name string
}
Worker gets an Info method through embedding Logger.
- Small Interfaces and Embedding: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Accept interfaces, return concrete types when that keeps APIs simple.
- Practice: Keep interfaces small and behavior-focused.
- Practice: Use embedding only when method promotion is part of the intended API.
- Avoid: Creating large interfaces before there are multiple implementations.
- Avoid: Using interface{} or any when a concrete type would be clearer.
- Avoid: Assuming embedding means inheritance.
- Small Interfaces and Embedding: references (review, 2 min) — Original references retained from the legacy library.
- Effective Go: Interfaces: https://go.dev/doc/effective_go#interfaces
- Go Blog: Laws of Reflection: https://go.dev/blog/laws-of-reflection
- Table Tests, Fakes, and Benchmarks: Table tests scale examples (concept, 48 min) — A table test stores cases as data and runs the same assertions for each case. Subtests make failures specific and allow focused runs with -run. Keep the table readable; if setup differs wildly, split tests.
- Retained source example: Table-driven test
func TestNormalizeEmail(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"lowercase", "USER@EXAMPLE.COM", "user@example.com"},
{"trim", " user@example.com ", "user@example.com"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeEmail(tt.in); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
Naming cases well makes failures actionable.
- Table Tests, Fakes, and Benchmarks: Fakes over fragile mocks (walkthrough, 48 min) — A fake is a small implementation used by tests. It can be clearer than a generated mock when the interface is tiny and behavior matters more than call ordering. Use integration tests for wiring that fakes cannot cover.
- Retained source example: Fake store
type fakeUserStore struct {
byID map[string]User
}
func (s fakeUserStore) FindUser(_ context.Context, id string) (User, error) {
u, ok := s.byID[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
The fake implements the same small interface as the production dependency.
- Table Tests, Fakes, and Benchmarks: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Test observable behavior rather than private implementation details.
- Practice: Use t.Helper in helper functions that report test failures.
- Practice: Keep interfaces small so fakes are easy to write.
- Avoid: Writing one giant table test with unreadable setup branches.
- Avoid: Using sleep-based tests for concurrency instead of synchronization.
- Avoid: Treating coverage percentage as a substitute for meaningful assertions.
- Table Tests, Fakes, and Benchmarks: references (review, 2 min) — Original references retained from the legacy library.
- Package testing: https://pkg.go.dev/testing
- Go Blog: Fuzzing is Beta Ready: https://go.dev/blog/fuzz-beta
- Package Design and Modules: Packages are API boundaries (concept, 40 min) — A package groups related code and exposes identifiers that start with capital letters. Good package names are short, lower-case, and describe what callers use, not an implementation layer. Avoid generic names like utils when a domain name would be clearer.
- Retained source example: Service project shape
go.mod
cmd/api/main.go
internal/users/service.go
internal/users/store.go
internal/httpapi/handlers.go
cmd contains executables; internal contains packages unavailable to outside modules.
- Package Design and Modules: Modules define dependency roots (walkthrough, 40 min) — A module is a versioned collection of packages. go.mod records the module path and dependency requirements. Keep module boundaries stable; splitting modules too early creates versioning overhead.
- Retained source example: go.mod example
module example.com/acme/orders
go 1.22
require github.com/google/uuid v1.6.0
The module path is the import path prefix for packages in the module.
- Package Design and Modules: practices and mistakes (review, 5 min) — Retained guidance from the legacy PTesting lesson.
- Practice: Name packages for what they provide to callers.
- Practice: Use internal to protect implementation packages from external import.
- Practice: Keep package cycles impossible by placing interfaces at consumer boundaries.
- Avoid: Creating package names like common, utils, or helpers for unrelated code.
- Avoid: Splitting packages by technical layer before behavior is clear.
- Avoid: Introducing import cycles through bidirectional dependencies.
- Package Design and Modules: references (review, 2 min) — Original references retained from the legacy library.
- Organizing a Go module: https://go.dev/doc/modules/layout
- Go Code Review Comments: Package Names: https://go.dev/wiki/CodeReviewComments#package-names
Code example
Go in orders/service.go.
package orders
import "context"
type PaymentGateway interface {
Charge(ctx context.Context, cents int64, token string) error
}
type Order struct {
PaymentToken string
totalCents int64
}
func (o Order) TotalCents() int64 {
return o.totalCents
}
type Service struct {
payments PaymentGateway
}
func (s Service) Checkout(ctx context.Context, order Order) error {
return s.payments.Charge(ctx, order.TotalCents(), order.PaymentToken)
}
Walkthrough examples
- Interfaces at Boundaries in a ticket processing platform — A team is extending a small Go service plus worker and needs this lesson's pattern to be clear enough for review, testing, and future maintenance.
- File: orders/service.go
- File: tests/interfaces-at-boundaries.spec
- File: docs/go-services/interfaces-at-boundaries.md
- Start from the provided Go snippet and make the intent visible in names and boundaries.
- Apply the checklist item "Find package owner" before adding extra behavior.
- Write down how the implementation changes when adapter fails or becomes slow.
- Consumer-owned interface — The consumer declares only the method it actually needs.
- Retained source code:
type EmailSender interface {
SendEmail(to, subject, body string) error
}
func Welcome(sender EmailSender, email string) error {
return sender.SendEmail(email, "Welcome", "Thanks for joining.")
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Embedding for shared behavior — Worker gets an Info method through embedding Logger.
- Retained source code:
type Logger struct{}
func (Logger) Info(msg string) {
fmt.Println("INFO", msg)
}
type Worker struct {
Logger
Name string
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Compile-time interface check — This pattern verifies that *bytes.Buffer satisfies io.Reader at compile time.
- Retained source code:
var _ io.Reader = (*bytes.Buffer)(nil)
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Table-driven test — Naming cases well makes failures actionable.
- Retained source code:
func TestNormalizeEmail(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"lowercase", "USER@EXAMPLE.COM", "user@example.com"},
{"trim", " user@example.com ", "user@example.com"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
if got := NormalizeEmail(tt.in); got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Fake store — The fake implements the same small interface as the production dependency.
- Retained source code:
type fakeUserStore struct {
byID map[string]User
}
func (s fakeUserStore) FindUser(_ context.Context, id string) (User, error) {
u, ok := s.byID[id]
if !ok {
return User{}, ErrNotFound
}
return u, nil
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Fuzz test seed — Fuzzing is useful for parsers and boundary-heavy functions.
- Retained source code:
func FuzzParseID(f *testing.F) {
f.Add("user-123")
f.Fuzz(func(t *testing.T, input string) {
_, _ = ParseID(input)
})
}
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Service project shape — cmd contains executables; internal contains packages unavailable to outside modules.
- Retained source code:
go.mod
cmd/api/main.go
internal/users/service.go
internal/users/store.go
internal/httpapi/handlers.go
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- go.mod example — The module path is the import path prefix for packages in the module.
- Retained source code:
module example.com/acme/orders
go 1.22
require github.com/google/uuid v1.6.0
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
- Avoid stutter — Call-site readability matters: package name plus identifier should read naturally.
- Retained source code:
// Good: users.Service
package users
type Service struct{}
// Avoid: users.UserService when Service is already inside package users.
- Explain the expected behavior.
- Compare the example with the canonical PTLearn implementation.
Practice
- Shrink one interface to the methods a consumer needs.
- Move an adapter to infrastructure code.
- Add a fake for a boundary test.
- Prefer a small interface: For a function that only needs Read, should it accept io.Reader or *os.File?
- Hint: Accept the smallest behavior the function needs.
- Reference solution: Accept io.Reader because it describes the required behavior without tying the function to files.
- Accepted answers: io.Reader | reader
- Run a named subtest: Which go test flag selects tests by name or regex?
- Expected output: -run
- Hint: Example: go test -run TestNormalizeEmail/lowercase
- Reference solution: Use the -run flag.
- Accepted answers: -run | run
- Internal visibility: Which directory name restricts imports to code inside the parent tree?
- Expected output: internal
- Hint: It is enforced by the go command.
- Reference solution: The internal directory restricts imports to the parent tree.
- Accepted answers: internal | internal/
Checklist
- Find package owner
- Trim interface methods
- Add adapter
- Write boundary test
Quiz prompts
- Where is a Go interface often most useful? — Consumer-owned interfaces describe what the caller needs without leaking implementation details.
- A teammate wants to hide consumer-owned interface inside a convenient helper. What should you check first? — Place consumer-owned interface at the boundary that keeps ticket processing platform behavior explicit, testable, and reviewable.
- Which artifact best proves this Go Services lesson is ready for review? — Production-ready learning needs evidence: a test, trace, command, screenshot, or log that catches the risk again.
- Intermediate package boundaries: a teammate says the happy path works, but "Consumer-owned interface" is still implicit. What should you ask for before merging? — Consumer-owned interface belongs in the intermediate stage only when the decision is visible, testable, and tied to a realistic failure mode.
- A reviewer has five minutes to evaluate this intermediate Go Services slice. Which evidence is strongest? — Refactor an intermediate Go service boundary with a consumer-owned interface, concrete adapter, and fake-backed behavior test.
- How does a type satisfy an interface in Go? — Interface satisfaction is structural and implicit.
- Where should many small interfaces be declared? — Consumer-owned interfaces avoid unnecessary coupling.
- What is a table-driven test? — The same test logic runs over multiple data cases.
- Which command reports benchmark allocation metrics? — -bench runs benchmarks and -benchmem reports allocation metrics.
- What makes an identifier exported from a Go package? — Export is controlled by the first letter's case.
- What file records module requirements? — go.mod stores the module path, Go version, and dependency requirements.
Flashcards
- Intermediate package boundaries: what decision does "Consumer-owned interface" force you to make? Define the smallest capability the service needs instead of mirroring implementation structs. Evidence prompt: Shrink one interface to the two methods the service actually calls.
- Intermediate package boundaries: what decision does "Adapter placement" force you to make? Keep external clients and database adapters outside business packages. Evidence prompt: Move one concrete adapter behind a package boundary and update imports.
- Intermediate package boundaries: what decision does "Test double contract" force you to make? Use fakes to test business behavior without mocking every internal method call. Evidence prompt: Replace one over-broad mock with a fake that stores only observable behavior.
- In Go Services, what should you remember about consumer-owned interface? consumer-owned interface matters here because it supports "Keep interfaces small.".
- In Go Services, what should you remember about adapter? adapter matters here because it supports "Mock only external boundaries.".
- In Go Services, what should you remember about package boundary? package boundary matters here because it supports "Avoid package cycles.".
- In Go Services, what should you remember about test double? test double matters here because it supports "Keep interfaces small.".
Labs
- Ship a interfaces at boundaries slice — Extend a small Go service plus worker with a small but reviewable feature that proves the lesson's architecture in code.
- Refactor an intermediate Go service boundary with a consumer-owned interface, concrete adapter, and fake-backed behavior test.
- Shrink one interface to the two methods the service actually calls.
- Move one concrete adapter behind a package boundary and update imports.
- Replace one over-broad mock with a fake that stores only observable behavior.
- Shrink one interface to the methods a consumer needs.
- Move an adapter to infrastructure code.
- The lab demonstrates the intermediate package boundaries outcome without skipping earlier contract evidence.
- Each subtopic has a concrete artifact: Consumer-owned interface, Adapter placement, Test double contract.
- The review notes explain how this level changes ownership, verification, or operational risk.
Challenge
- Review-ready interfaces at boundaries (Core) — Refactor an intermediate Go service boundary with a consumer-owned interface, concrete adapter, and fake-backed behavior test.
- All checklist items are either implemented or documented with a reason.
- The change can be understood from orders/service.go plus one short note.
- The concept diagram names ownership, failure handling, and verification points.
- A teammate could run the verification steps without asking for hidden context.
Canonical lesson URL