- Book: Thinking in Go (2-book series) — Complete Guide to Go + Hexagonal Architecture in Go
- Also by me: Observability for LLM Applications · Ebook from Apr 22
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your Spring Boot app takes 40 seconds to start. Your Docker image is 600MB. Your team is twelve engineers and half the PRs this sprint are about annotation configuration. The staging cluster runs eight pods because each one pins a 1.2GB heap before it serves a request, and the platform team just sent another Slack message about node pressure.
This is what 2025 looked like for a lot of Fortune 500 platform groups. What happened next is the thing nobody writes a press release about: a quiet, targeted rewrite of the services that hurt the most, from Java to Go. Not the whole monolith. Not the CRUD over Postgres. The stuff at the edge that had to be cheap, small, and fast to restart.
Uber's Schemaless and Ringpop years set the template back in the 2015–2018 window. Cloudflare has been publicly writing about Go as the default for new control-plane services for most of a decade. Twitch talked in detail about what pushed their chat ingest off the JVM and into Go. The pattern did not start in 2026. What changed in 2026 is that the teams behind those migrations are no longer outliers. They are the reference architecture your architecture review board is quoting back at you.
This post is not an argument that you should rewrite your monolith. It is a side-by-side for the Java developer whose platform team has just announced that the next greenfield service is going in Go, and who wants to know what actually changes.
flowchart LR
subgraph JVM["Spring Boot startup"]
J1[java -jar] --> J2[Classpath scan]
J2 --> J3[Bean definitions]
J3 --> J4[Reflection wiring]
J4 --> J5[Start embedded Tomcat]
J5 --> J6[Ready ~40s]
end
subgraph GOAPP["Go startup"]
G1[./app] --> G2[main runs]
G2 --> G3[Explicit wiring]
G3 --> G4[net/http listen]
G4 --> G5[Ready ~50ms]
end
Dependency injection: annotations to constructors
Spring's DI container is the thing a Java developer stops thinking about six weeks into a job. You annotate a class with @Service, you declare a field with @Autowired or a constructor parameter, and the framework wires the graph at startup. It is convenient, and it is also the reason your application context takes 12 seconds to boot and your stack traces start with forty frames of reflection.
Go has no DI framework in the standard sense. The equivalent is a constructor function and a main.go that wires things by hand.
// Java (Spring)
@Service
public class OrderService {
@Autowired
private PaymentGateway gateway;
@Autowired
private OrderRepository repo;
}
// Go
type OrderService struct {
gateway PaymentGateway
repo OrderRepository
}
func NewOrderService(g PaymentGateway, r OrderRepository) *OrderService {
return &OrderService{gateway: g, repo: r}
}
// main.go
repo := NewPostgresOrderRepo(db)
gw := NewStripeGateway(apiKey)
svc := NewOrderService(gw, repo)
The wiring is now a function you can read top to bottom. There is no container, no scan, no startup hook. For a twelve-service microservice fleet this feels primitive for about a week, and then it feels like the thing you always wanted. A new hire reads main.go and knows the whole service graph. Nobody has to remember what @ComponentScan does at 2am.
Spring Boot starters: stdlib plus three libraries
The spring-boot-starter-web model bundles Tomcat, Jackson, validation, auto-configuration, and an opinionated set of defaults into one import. The tradeoff is that mvn dependency:tree prints 180 lines.
A Go service in 2026 for the same job looks like:
-
net/httpfrom the standard library for the server. -
encoding/jsonfrom the standard library for serialization. -
chior the stdlibhttp.ServeMux(Go 1.22+ has proper routing) for routes. -
slogfrom the standard library for structured logging. - Maybe
validatorfor request validation.
That is the dependency list for a lot of production services. go.mod fits on a phone screen. The implication is not that Go is better at HTTP. The implication is that the starter-pack model is solving a problem Go mostly does not have.
JPA and Hibernate: sqlc instead
This is the transition that Java developers push back on hardest, because Hibernate's object graph is genuinely clever. @OneToMany, @ManyToOne, lazy loading, the L2 cache, criteria builders. A senior Java backend has years of muscle memory in that machine.
Go's dominant pattern in 2026 is sqlc: you write the SQL, the tool generates typed Go functions that wrap it.
-- queries.sql
-- name: GetOrderByID :one
SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE id = $1;
// Generated by sqlc.
func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
row := q.db.QueryRowContext(ctx, getOrderByID, id)
var o Order
err := row.Scan(&o.ID, &o.CustomerID, &o.TotalCents, &o.CreatedAt)
return o, err
}
The code you write is SQL. The code that executes is SQL. The code that gets reviewed is SQL. The loss is real: you do not get a lazy-loaded customer graph off an order.getCustomer() call. The gain is that the N+1 problem stops being a runtime surprise because there is no framework hiding a query from you.
Teams that have done this migration tend to describe it the same way: the first two weeks hurt, and then code review gets 40% shorter because you can see what the database is doing on page one.
Interfaces: explicit implements, implicit satisfies
// Java
public interface PaymentGateway {
PaymentResult charge(Money amount, CardToken card);
}
public class StripeGateway implements PaymentGateway {
public PaymentResult charge(Money amount, CardToken card) { ... }
}
// Go
type PaymentGateway interface {
Charge(ctx context.Context, amount Money, card CardToken) (PaymentResult, error)
}
type StripeGateway struct { apiKey string }
// No "implements" keyword. If the methods match, the interface is satisfied.
func (s *StripeGateway) Charge(ctx context.Context, amount Money, card CardToken) (PaymentResult, error) {
...
}
Java's implements is explicit at the type declaration. Go's structural satisfaction means an interface is satisfied by any type with the right methods, and nothing has to announce the relationship.
For a Java team this feels wrong for about a day. Then two things click. First, consumers define interfaces where they are consumed, not where the type is declared, so you can slice a 10-method legacy type down to the 2 methods your package actually needs. Second, test doubles are hand-written structs that satisfy the same interface without any mocking framework. Mockito disappears from the build. gomock and mockery exist, but a lot of Go codebases go five years without using either.
Checked exceptions vs explicit errors
This is the one that causes the loudest pushback and the one that pays off the most.
// Java
try {
PaymentResult r = gateway.charge(amount, card);
return Response.ok(r);
} catch (PaymentDeclinedException e) {
return Response.status(402).body(e.getMessage());
} catch (PaymentProviderException e) {
logger.error("gateway failure", e);
return Response.status(502).build();
}
// Go
r, err := gateway.Charge(ctx, amount, card)
if err != nil {
var declined *PaymentDeclined
if errors.As(err, &declined) {
return respond(w, 402, declined.Reason)
}
slog.Error("gateway failure", "err", err)
return respond(w, 502, nil)
}
return respond(w, 200, r)
Go has no checked exceptions. Errors are values, returned alongside results. You type if err != nil a lot. Java developers look at this and assume they have been sent to punishment.
The thing that shifts over six months is that the error path is in the same 80 characters of code as the happy path. Nothing is hidden in a throws clause. Nothing unwinds the stack three frames up past your @ControllerAdvice. You can read a function and see exactly what can go wrong, because everything that can go wrong is assigned to a variable right there.
The cost is verbosity. Go 1.22 and the rejected try proposal debate from years ago are both real. The team has decided verbosity is the price. After twelve months in a Go codebase, most Java engineers stop missing exceptions and start missing them the way they miss XML config files. Which is to say, not much.
JVM memory vs Go runtime memory
A freshly-booted Spring Boot service with Jackson, Hibernate, Tomcat, and three starters sits at roughly 350-500MB resident before the first request. You size the pod at 1GB because the JVM will grow into whatever heap you give it and you want some headroom. Eight pods across availability zones and you are at 8GB of memory for a service that does light CRUD.
A Go binary doing the same job starts at 15-30MB resident. Goroutines cost a few KB of stack each. The 10x number in the headline of this kind of post is not marketing; it is what teams report back after the migration, and it is the single biggest reason a CFO signs off.
Discord's 2020 writeup is famous as a Go-to-Rust story, but read the first half: the Go service they replaced was already an order of magnitude smaller than the Java pipeline it had replaced before that. At Cloudflare's scale, the memory profile determines the hardware bill. At a 200-service enterprise fleet, it determines whether you need a new cluster.
Build systems: Maven/Gradle vs go.mod
Your pom.xml is 400 lines. Your build.gradle.kts has three layers of plugin configuration. Your CI cache lives and dies on ~/.m2. Parallelizing the build requires convincing Gradle to actually do it.
// go.mod
module github.com/acme/orders
go 1.22
require (
github.com/go-chi/chi/v5 v5.1.0
github.com/jackc/pgx/v5 v5.5.5
github.com/sqlc-dev/sqlc v1.26.0 // indirect
)
That is a production go.mod. The build command is go build ./.... Cold builds for a 50-package service take a handful of seconds on a laptop. There is no wrapper script. There is no -T 1C flag. There is no plugin that nobody on the team understands that adds six seconds to every run.
You lose Gradle's flexibility. You lose the Maven plugin ecosystem. Neither loss shows up in the retrospective after a migration, because the thing you actually did with those tools was wait for them to finish.
Reflection: Java heavy, Go minimal
Reflection is the load-bearing mechanism of the Spring universe. Jackson uses it to serialize. Hibernate uses it to map rows. Mockito uses it to stub. Your APM agent uses it to instrument bytecode at runtime. When you deploy, a nontrivial fraction of startup cost is reflection scanning your classpath.
Go has a reflect package. It is there. Standard-library JSON uses it. After that, most production Go code does not touch it. Serialization happens through struct tags the compiler can see. Test doubles are hand-written. Observability is through OpenTelemetry instrumentation the developer adds explicitly, not a bytecode-rewriting agent.
The consequence is that what you read is what runs. Stack traces fit on one screen. There is no cglib frame, no ByteBuddy, no mystery proxy object. The flip side: if you loved Spring AOP, you will not find a replacement. Cross-cutting concerns in Go are middleware functions and decorator patterns, explicit and ugly and easy to grep.
A Spring Boot controller next to its Go equivalent
The classic CRUD endpoint.
// Java
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService svc;
@Autowired
public OrderController(OrderService svc) { this.svc = svc; }
@GetMapping("/{id}")
public ResponseEntity<Order> get(@PathVariable UUID id) {
return svc.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
// Go (stdlib + chi)
func (h *OrderHandler) Routes(r chi.Router) {
r.Get("/orders/{id}", h.get)
}
func (h *OrderHandler) get(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
order, err := h.svc.FindByID(r.Context(), id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(order)
}
The Go version is longer in lines. It is also longer in the way a flight checklist is longer than "take off and land." Every branch is explicit. r.Context() carries cancellation and the trace through every downstream call. Nothing gets unmarshaled by an annotation you forgot existed.
Why the rewrite happened where it happened
The services that made the jump first at Uber, Cloudflare, and Twitch had the same shape: high-throughput, I/O-bound, edge-adjacent, and they were already painful to operate on the JVM. Uber's Ringpop write-up, Cloudflare's consistent drumbeat of Go posts, Twitch's chat ingest rewrite, Uber's schemaless datastore. None of them rewrote the monolith. They rewrote the thing whose shape fit Go: small, concurrent, restart-friendly, memory-frugal.
Platform leads pick Go for that shape because deploys are a single static binary, cold starts are measured in milliseconds, and code review of a 300-line handler fits in a single sitting. The review dimension is the one that gets underweighted in comparison posts. At twelve engineers on a service, the thing that determines velocity is how many minutes each PR sits in review. A Go PR without annotation magic is a PR that reviews fast.
What Java still does better in 2026
Not a concession paragraph. A real list.
Ecosystem maturity. Twenty-five years of libraries. If the thing you need to integrate with is an enterprise system built before 2015, there is a Java client that handles edge cases the Go client has not met yet. OAuth flavors, SOAP dialects, mainframe connectors, specific flavors of Kerberos. The Java world has them.
IDE tooling. IntelliJ IDEA for Java remains the best enterprise IDE ever built. Structural search and replace, refactoring that actually refactors, debugger state you can edit live. GoLand is strong, but the Java tooling has a decade of lead.
Enterprise support contracts. Oracle, Red Hat, IBM will all sell you a JVM with a support contract, SLAs, and CVE response commitments. The Go equivalent is Google's toolchain with community response times. For regulated industries where a support contract is procurement's hard gate, Java is not going anywhere.
Observability depth in legacy stacks. If your shop has ten years of New Relic, AppDynamics, or Dynatrace instrumentation for the JVM, the Go story is less mature. OpenTelemetry has closed most of the gap, but the vendor-native agent experience is richer on the JVM.
Team liquidity. You can hire a senior Java engineer in any major city on a month's notice. The Go hiring pool is smaller. At a 200-engineer org, that matters.
The honest framing
Nobody serious is arguing that every Java service should be in Go. The teams migrating in 2026 are picking specific services where the memory profile, startup time, or deploy simplicity actually moves the business. The rest of the monolith stays. The Jakarta EE app running the general ledger is not going anywhere. The Spring Boot internal tool that three people use is not worth a rewrite.
What has shifted is the default for greenfield. Ten years ago a new service at an enterprise shop was Spring Boot by inertia. In 2026 the architecture review board wants to know why it is not Go, and the answers they are satisfied with are specific ones: we need the JPA ORM because the domain is a graph, we need the library ecosystem because it talks to a vendor system, we need the team expertise because the on-call rotation is already Java-heavy.
If the answer is "because that is what we have always written," the service is going in Go.
flowchart TD
A[JVM Spring Boot vs Go — relative cost] --> B[Startup time]
A --> C[Memory footprint]
A --> D[Container image]
B --> B1[JVM: 40s]
B --> B2[Go: 50ms]
C --> C1[JVM: 500+ MB]
C --> C2[Go: 20-50 MB]
D --> D1[JVM: 300+ MB]
D --> D2[Go: 10-20 MB]
If this was useful
If you are the Java engineer reading this because your team picked Go for the next service, the book I would point you at is Thinking in Go — two volumes, one for the language from scratch, one for the service shape (hexagonal architecture) that maps closest to how a Spring Boot developer already thinks about separation of concerns. It is the fastest bridge between the muscle memory you have and the code you are about to write.
- Thinking in Go (2-book series): Complete Guide to Go Programming · Hexagonal Architecture in Go
- Observability for LLM Applications — Amazon · Ebook from Apr 22
- Hermes IDE — hermes-ide.com · GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- More writing — xgabriel.com · GitHub


Top comments (0)