Extracting a framework instead of designing one
Medusa came out of four production Go services that had ended up with the same structure. I pulled the shared parts into a framework, then put it back into the services it came from.
Most frameworks get written in the wrong order. Someone imagines the applications people will build, designs abstractions for them, and finds out later that the abstractions were guesses.
Medusa went the other way around. By the time I wrote the first line of it I had shipped four production Go services for different clients in different industries. They shared no code, but reading them side by side they had the same skeleton.
Repetition across codebases
What made it worth extracting was that the similarity happened without coordination. Four codebases written months apart under different constraints had landed on the same boundary between transport, use case, and persistence. That is better evidence than my own preference.
// The handler knows about HTTP. The service does not.
// This boundary survived every codebase it was extracted from.
type Service interface {
Create(ctx context.Context, input CreateInput) (*Resource, error)
}
func Handle(svc Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
input, err := decode[CreateInput](r)
if err != nil {
respond.Error(w, http.StatusBadRequest, err)
return
}
resource, err := svc.Create(r.Context(), input)
if err != nil {
respond.Error(w, statusFor(err), err)
return
}
respond.JSON(w, http.StatusCreated, resource)
}
}The validation loop
Extraction on its own proves nothing, so every abstraction had to make a round trip: pull it out, then deploy it back into the systems it came from. If moving a service onto the framework version made that service worse, the abstraction was wrong and got deleted.
- 01 Identify a pattern that appears in at least three of the four services.
- 02 Extract the smallest version of it that covers all three call sites.
- 03 Migrate one service to it and measure the diff in real code, not in principle.
- 04 If the migration adds indirection without removing decisions, discard it.
Step four killed more candidates than the rest combined. Configurable middleware chains, a generic repository layer, a pluggable event bus: all fine in isolation, all of them made the calling code harder to read.
Where it landed
The result is smaller than what I would have designed on a whiteboard, and every piece of it has a production system behind it. When someone asks why a boundary sits where it does, the answer is that four codebases put it there before I did.