From Monolith to First Go Microservice, A Pragmatic Cutover
TL;DR — The first microservice should be the most boring one you can find. Cut it out behind a feature flag. Shadow traffic for a week. Promote when the diff is zero. Repeat.
The monolith is in a container now (covered in the previous post and the two before it). The next question is harder: when do you start splitting it, and what comes out first?
I’ve watched two teams do this badly. One picked the most exciting service to extract first — the search engine, with full-text fanciness, ranking experiments, the works. Three months later they were still debugging duplicate-result bugs and the monolith hadn’t gotten any smaller. The other team tried to extract billing first because “it’s the most important.” They got six weeks in before realising they couldn’t actually decouple it from the user table without a six-month data migration.
The pattern that works: pick the most boring slice you have. Something with a clear input, a clear output, no shared mutable state, and no business stakeholder breathing down your neck. For us that was a notifications service — fire-and-forget email and SMS sending. This post walks through how the cutover worked.
Pick a service that’s safe to fail
Three criteria for the first microservice:
Async or fire-and-forget. If a request can fail and be retried without a user noticing, it’s a good first candidate. Notifications, audit logging, image resizing, async exports — all qualify. Synchronous request paths from the user-facing site are not first-microservice material.
Clean data ownership. The monolith should write into this domain and nothing else should read from it. If five other modules in the monolith also touch the notifications table, you have to detangle them first. For us, the notifications table was already isolated — only the notification module wrote to it — so the data extraction was straightforward.
Low business risk. If the service is down for an hour, what breaks? For notifications: an email goes out late. For billing: revenue stops. The first microservice should be something where “down for an hour” isn’t a P0.
Notifications hit all three. Three weeks of work, end-to-end.
The cutover pattern
This is the part that most “monolith to microservices” posts skip. They show the after-state and gloss over how you get there without breaking production. Here’s the pattern that worked.
Step 1: Build the new service in parallel. Don’t touch the monolith yet. The Go service is a fresh project, with its own repo (or directory), its own Postgres database (or schema), its own deployment. It accepts the same inputs the monolith’s notification module would — typically a JSON payload representing “send this email to this user.”
// cmd/notifications/main.go
package main
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"github.com/yourorg/notifications/internal/api"
"github.com/yourorg/notifications/internal/store"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := store.Open(ctx)
if err != nil {
log.Fatalf("db open: %v", err)
}
defer db.Close()
h := api.NewHandler(db)
srv := &http.Server{
Addr: ":8080",
Handler: h,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("notifications listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
Boring on purpose. Standard library net/http, no framework, signal-aware shutdown. Same pattern in every Go service we’ll build this year.
Step 2: Add a feature-flagged dual-write. The monolith starts writing to both the old in-process notification module and the new HTTP service. The user-facing behaviour stays identical (the old module is still the source of truth). The new service is just a shadow consumer.
// In the monolith, NotificationDispatcher
public function dispatch(Notification $n): void
{
// Still the source of truth
$this->legacyDispatcher->send($n);
// Shadow the new service
if ($this->featureFlag->enabled('shadow_notifications')) {
try {
$this->httpClient->post(
config('services.notifications.url') . '/v1/notifications',
['json' => $n->toArray(), 'timeout' => 2.0]
);
} catch (\Throwable $e) {
// Log only. Never fail the request.
$this->logger->warning('shadow notify failed', ['err' => $e->getMessage()]);
}
}
}
Step 3: Compare outputs for a week. With the shadow running, you have two data sources for the same events. Diff them daily. Did every email the monolith sent get queued by the new service? Did the new service queue anything the monolith didn’t? Were timestamps within 100 ms?
This is where you find the bugs. Always. Things you “knew” the monolith did turn out to have edge cases you forgot. Things the new service does turn out to have subtly different timezone handling. Find these now, with no user impact.
Step 4: Flip authority. When the diff is zero for several days running, swap the source of truth. The new service becomes authoritative. The monolith’s call site reverses: it calls the new service first, and only falls back to the legacy module on failure.
Step 5: Remove the legacy module. A week or two after the flip, with no fallback hits in the logs, delete the old code from the monolith. The monolith just shrank.
Code organization
Quick note on layout because this question comes up. The Go service starts with the simplest possible structure:
notifications/
cmd/
notifications/
main.go
internal/
api/
handler.go
handler_test.go
notify/
service.go
service_test.go
store/
postgres.go
postgres_test.go
go.mod
go.sum
Dockerfile
docker-compose.yml
Three internal packages: HTTP transport (api), business logic (notify), storage (store). One binary. No interfaces until a second implementation actually exists. No dependency injection framework. Standard library plus pgx for Postgres and chi for routing.
The Dockerfile is the multi-stage Go pattern from the previous post. Final image is ~14 MB.
Common Pitfalls
Trying to share the monolith’s database from day one. Tempting because “we’ll just point at the same table.” Don’t. The new service gets its own database (or at minimum its own schema). The dual-write phase is what reconciles them. Sharing the schema couples the two services at the data layer, which defeats the point of the extraction.
Skipping the shadow phase. It feels slow. It is slow. It’s also the only way to be honest about whether the new service does what you think the old one does. Skipping it is how you learn that 0.3% of notifications go through a code path nobody documented.
Picking a service with a chatty integration. If the candidate service makes 50 HTTP calls back to the monolith per request, you’ve moved the complexity into the network instead of removing it. Boring services have boring integrations.
Two repos before you’ve shipped one service. It’s tempting to set up the polyrepo structure, the shared library, the proto monorepo. None of that helps shipping the first service. Ship one service, then learn what shape your shared concerns actually take.
Wrapping Up
The first microservice is a credibility exercise. Pick something that can fail without anyone caring, ship it through a shadow → cutover pattern, and use it to learn your team’s actual operational patterns for things like CI, deploys, secrets, and logging. The next post in this series — designing the service boundary for the billing module split — assumes you’ve earned the right to extract something harder.