“We need to scale the task management system, but the monolith is holding us back.” This statement kicked off a nine-month migration project that transformed a 150,000-line Go monolith into a distributed microservices architecture. The journey was challenging, filled with architectural decisions, technical challenges, and valuable lessons about when (and when not) to break apart a monolith.

This post chronicles that migration, from initial assessment through production deployment, including the architectural patterns, code examples, and hard-won lessons that made the difference between success and failure. If you’re considering a similar migration, this is the comprehensive guide I wish we’d had at the start.

The Starting Point: Taskman Monolith

Application Overview

Taskman was a task management and workflow automation platform serving 50,000+ users across 200+ enterprise customers. The monolithic application handled:

  • User authentication and authorization
  • Task creation, assignment, and tracking
  • Workflow definition and execution
  • Notification delivery (email, SMS, webhooks)
  • Reporting and analytics
  • File storage and processing
  • API integrations with external systems

The Monolith Architecture

taskman/
├── cmd/
│   └── server/
│       └── main.go           # 500 lines
├── internal/
│   ├── auth/                 # 15,000 lines
│   ├── tasks/                # 35,000 lines
│   ├── workflows/            # 25,000 lines
│   ├── notifications/        # 12,000 lines
│   ├── reports/              # 20,000 lines
│   ├── storage/              # 8,000 lines
│   ├── integrations/         # 18,000 lines
│   └── database/             # 7,000 lines
├── pkg/
│   ├── models/               # 10,000 lines
│   └── utils/                # 5,000 lines
└── vendor/                   # Dependencies

Total: ~150,000 lines of Go code
Single PostgreSQL database: 143 tables
Deployment: Single binary, 6 instances behind ALB

The Problems

1. Scaling Limitations

Different components had vastly different resource requirements:

Component          CPU Usage    Memory Usage    Scale Needs
-----------------------------------------------------------------
Auth               Low (5%)     Low (200MB)     Rarely
Task Management    Medium (30%) Medium (800MB)  Moderate
Workflow Engine    High (70%)   High (2GB)      Frequent
Notifications      Low (10%)    Low (300MB)     Spiky
Reports            Very High    Very High       Scheduled
Integrations       Medium       Medium          Per-customer

When workflow execution spiked, we had to scale the entire application, wasting resources on components that didn’t need scaling.

2. Deployment Risk

Every deployment was high-risk:

  • 15-20 minute deployment window
  • Required coordination across teams
  • Small changes to notifications required full application deployment
  • Rollback meant reverting everything, not just the problematic component
  • Database migrations blocked all deployments

3. Development Velocity

  • Build time: 8 minutes for full binary
  • Test suite: 45 minutes for complete run
  • Tight coupling made changes risky
  • Multiple teams blocking each other
  • Difficult to onboard new developers

4. Database Bottleneck

All components shared a single PostgreSQL instance:

-- Query from task service
SELECT * FROM tasks WHERE user_id = $1;

-- Simultaneously, workflow engine running
SELECT * FROM workflow_executions
WHERE status = 'running'
ORDER BY created_at
LIMIT 1000;

-- And reporting service executing
SELECT date_trunc('day', created_at), count(*)
FROM tasks
WHERE created_at > now() - interval '90 days'
GROUP BY 1;

Result: Lock contention, slow queries, connection pool exhaustion.

Migration Strategy

Phase 1: Assessment and Planning

We used the Strangler Fig pattern, gradually extracting services while maintaining the monolith. The extraction order was based on:

  1. Business value: Which services provide immediate ROI?
  2. Independence: Which components have minimal dependencies?
  3. Resource needs: Which components have distinct scaling requirements?
  4. Change frequency: Which components change most often?

Our extraction order:

Phase 1 (Months 1-3):
  1. Notifications Service    (Low coupling, clear boundaries)
  2. Reports Service           (High resource usage, scheduled)

Phase 2 (Months 4-6):
  3. Integrations Service      (Per-customer scaling)
  4. Storage Service           (Distinct infrastructure needs)

Phase 3 (Months 7-9):
  5. Workflow Service          (Core business logic, complex)
  6. Task Service              (Central to application)
  7. Auth Service              (Critical, extract last)

Phase 2: Service Boundaries

We applied Domain-Driven Design to define service boundaries:

// Before: All in monolith
package models

type Task struct {
    ID              string
    UserID          string
    Title           string
    Description     string
    Status          string
    AssignedTo      string
    CreatedAt       time.Time
    UpdatedAt       time.Time
    // 30+ more fields mixing concerns
    NotificationsSent   []Notification
    WorkflowExecutionID string
    FileAttachments     []File
    IntegrationData     map[string]interface{}
}

// After: Clear boundaries
// Task Service
package task

type Task struct {
    ID          string
    UserID      string
    Title       string
    Description string
    Status      TaskStatus
    AssignedTo  string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// Notification Service
package notification

type TaskNotification struct {
    TaskID      string  // Reference, not embedded
    RecipientID string
    Type        NotificationType
    SentAt      time.Time
}

// Workflow Service
package workflow

type WorkflowExecution struct {
    ID          string
    WorkflowID  string
    TaskID      string  // Reference
    Status      ExecutionStatus
    StartedAt   time.Time
    CompletedAt *time.Time
}

Implementation: Service Extraction

Example 1: Notifications Service

The notifications service was our first extraction - relatively independent with clear boundaries.

Service Structure

notifications-service/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── api/
│   │   ├── grpc/
│   │   │   ├── server.go
│   │   │   └── handlers.go
│   │   └── rest/
│   │       ├── server.go
│   │       └── handlers.go
│   ├── domain/
│   │   ├── notification.go
│   │   ├── repository.go
│   │   └── service.go
│   ├── providers/
│   │   ├── email/
│   │   │   ├── smtp.go
│   │   │   └── sendgrid.go
│   │   ├── sms/
│   │   │   └── twilio.go
│   │   └── webhook/
│   │       └── client.go
│   └── storage/
│       └── postgres/
│           └── repository.go
├── pkg/
│   └── pb/
│       └── notifications.pb.go
├── deployments/
│   └── kubernetes/
│       ├── deployment.yaml
│       └── service.yaml
└── proto/
    └── notifications.proto

gRPC Service Definition

// proto/notifications.proto
syntax = "proto3";

package notifications.v1;

option go_package = "github.com/taskman/notifications-service/pkg/pb";

service NotificationService {
  // Send a notification
  rpc SendNotification(SendNotificationRequest) returns (SendNotificationResponse);

  // Get notification status
  rpc GetNotification(GetNotificationRequest) returns (Notification);

  // List notifications for a user
  rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse);

  // Mark notification as read
  rpc MarkAsRead(MarkAsReadRequest) returns (MarkAsReadResponse);

  // Stream notification events
  rpc StreamNotifications(StreamNotificationsRequest) returns (stream NotificationEvent);
}

message SendNotificationRequest {
  string user_id = 1;
  NotificationType type = 2;
  string subject = 3;
  string body = 4;
  map<string, string> metadata = 5;
  Priority priority = 6;
}

message SendNotificationResponse {
  string notification_id = 1;
  NotificationStatus status = 2;
}

message Notification {
  string id = 1;
  string user_id = 2;
  NotificationType type = 3;
  string subject = 4;
  string body = 5;
  NotificationStatus status = 6;
  int64 created_at = 7;
  int64 sent_at = 8;
  int64 read_at = 9;
  map<string, string> metadata = 10;
}

enum NotificationType {
  EMAIL = 0;
  SMS = 1;
  WEBHOOK = 2;
  IN_APP = 3;
}

enum NotificationStatus {
  PENDING = 0;
  SENT = 1;
  DELIVERED = 2;
  FAILED = 3;
  READ = 4;
}

enum Priority {
  LOW = 0;
  NORMAL = 1;
  HIGH = 2;
  URGENT = 3;
}

message GetNotificationRequest {
  string notification_id = 1;
}

message ListNotificationsRequest {
  string user_id = 1;
  int32 page_size = 2;
  string page_token = 3;
  NotificationStatus status = 4;
  NotificationType type = 5;
}

message ListNotificationsResponse {
  repeated Notification notifications = 1;
  string next_page_token = 2;
  int32 total_count = 3;
}

message MarkAsReadRequest {
  string notification_id = 1;
  string user_id = 2;
}

message MarkAsReadResponse {
  bool success = 1;
}

message StreamNotificationsRequest {
  string user_id = 1;
}

message NotificationEvent {
  string event_type = 1;
  Notification notification = 2;
  int64 timestamp = 3;
}

Domain Service Implementation

// internal/domain/service.go
package domain

import (
    "context"
    "fmt"
    "time"

    "github.com/google/uuid"
    "go.uber.org/zap"
)

type NotificationService struct {
    repo      Repository
    providers map[NotificationType]NotificationProvider
    logger    *zap.Logger
}

func NewNotificationService(
    repo Repository,
    providers map[NotificationType]NotificationProvider,
    logger *zap.Logger,
) *NotificationService {
    return &NotificationService{
        repo:      repo,
        providers: providers,
        logger:    logger,
    }
}

func (s *NotificationService) SendNotification(
    ctx context.Context,
    req *SendNotificationRequest,
) (*Notification, error) {
    // Create notification entity
    notification := &Notification{
        ID:        uuid.New().String(),
        UserID:    req.UserID,
        Type:      req.Type,
        Subject:   req.Subject,
        Body:      req.Body,
        Priority:  req.Priority,
        Status:    NotificationStatusPending,
        Metadata:  req.Metadata,
        CreatedAt: time.Now(),
    }

    // Persist notification
    if err := s.repo.Create(ctx, notification); err != nil {
        s.logger.Error("Failed to create notification",
            zap.Error(err),
            zap.String("user_id", req.UserID),
        )
        return nil, fmt.Errorf("failed to create notification: %w", err)
    }

    // Send asynchronously based on priority
    if notification.Priority == PriorityUrgent {
        // Send immediately
        go s.sendNotification(context.Background(), notification)
    } else {
        // Queue for batch processing
        if err := s.queueNotification(ctx, notification); err != nil {
            s.logger.Warn("Failed to queue notification",
                zap.Error(err),
                zap.String("notification_id", notification.ID),
            )
        }
    }

    return notification, nil
}

func (s *NotificationService) sendNotification(ctx context.Context, notification *Notification) {
    provider, exists := s.providers[notification.Type]
    if !exists {
        s.logger.Error("No provider for notification type",
            zap.String("type", string(notification.Type)),
        )
        s.updateStatus(ctx, notification.ID, NotificationStatusFailed)
        return
    }

    // Attempt to send with retries
    maxRetries := 3
    var lastErr error

    for attempt := 0; attempt < maxRetries; attempt++ {
        if attempt > 0 {
            // Exponential backoff
            backoff := time.Duration(1<<uint(attempt)) * time.Second
            time.Sleep(backoff)
        }

        err := provider.Send(ctx, notification)
        if err == nil {
            // Success
            notification.SentAt = time.Now()
            notification.Status = NotificationStatusSent
            s.repo.Update(ctx, notification)

            s.logger.Info("Notification sent successfully",
                zap.String("notification_id", notification.ID),
                zap.String("type", string(notification.Type)),
                zap.Int("attempt", attempt+1),
            )
            return
        }

        lastErr = err
        s.logger.Warn("Failed to send notification",
            zap.Error(err),
            zap.String("notification_id", notification.ID),
            zap.Int("attempt", attempt+1),
        )
    }

    // All retries failed
    notification.Status = NotificationStatusFailed
    s.repo.Update(ctx, notification)

    s.logger.Error("Notification failed after retries",
        zap.Error(lastErr),
        zap.String("notification_id", notification.ID),
        zap.Int("max_retries", maxRetries),
    )
}

func (s *NotificationService) GetNotification(
    ctx context.Context,
    notificationID string,
) (*Notification, error) {
    return s.repo.GetByID(ctx, notificationID)
}

func (s *NotificationService) ListNotifications(
    ctx context.Context,
    req *ListNotificationsRequest,
) ([]*Notification, string, error) {
    notifications, err := s.repo.List(ctx, &RepositoryListOptions{
        UserID:    req.UserID,
        Status:    req.Status,
        Type:      req.Type,
        PageSize:  req.PageSize,
        PageToken: req.PageToken,
    })
    if err != nil {
        return nil, "", err
    }

    // Generate next page token
    var nextToken string
    if len(notifications) == req.PageSize {
        nextToken = notifications[len(notifications)-1].ID
    }

    return notifications, nextToken, nil
}

func (s *NotificationService) MarkAsRead(
    ctx context.Context,
    notificationID, userID string,
) error {
    notification, err := s.repo.GetByID(ctx, notificationID)
    if err != nil {
        return err
    }

    // Verify ownership
    if notification.UserID != userID {
        return ErrUnauthorized
    }

    now := time.Now()
    notification.ReadAt = &now
    notification.Status = NotificationStatusRead

    return s.repo.Update(ctx, notification)
}

func (s *NotificationService) queueNotification(ctx context.Context, notification *Notification) error {
    // Implementation depends on message queue (NATS, Kafka, RabbitMQ, etc.)
    // For now, simplified version
    return nil
}

func (s *NotificationService) updateStatus(ctx context.Context, id string, status NotificationStatus) {
    notification, err := s.repo.GetByID(ctx, id)
    if err != nil {
        s.logger.Error("Failed to get notification for status update",
            zap.Error(err),
            zap.String("notification_id", id),
        )
        return
    }

    notification.Status = status
    if err := s.repo.Update(ctx, notification); err != nil {
        s.logger.Error("Failed to update notification status",
            zap.Error(err),
            zap.String("notification_id", id),
        )
    }
}

gRPC Server Implementation

// internal/api/grpc/server.go
package grpc

import (
    "context"

    "github.com/taskman/notifications-service/internal/domain"
    pb "github.com/taskman/notifications-service/pkg/pb"
    "go.uber.org/zap"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

type Server struct {
    pb.UnimplementedNotificationServiceServer
    service *domain.NotificationService
    logger  *zap.Logger
}

func NewServer(service *domain.NotificationService, logger *zap.Logger) *Server {
    return &Server{
        service: service,
        logger:  logger,
    }
}

func (s *Server) SendNotification(
    ctx context.Context,
    req *pb.SendNotificationRequest,
) (*pb.SendNotificationResponse, error) {
    // Validate request
    if err := validateSendRequest(req); err != nil {
        return nil, status.Errorf(codes.InvalidArgument, "invalid request: %v", err)
    }

    // Convert to domain model
    domainReq := &domain.SendNotificationRequest{
        UserID:   req.UserId,
        Type:     domain.NotificationType(req.Type),
        Subject:  req.Subject,
        Body:     req.Body,
        Priority: domain.Priority(req.Priority),
        Metadata: req.Metadata,
    }

    // Send notification
    notification, err := s.service.SendNotification(ctx, domainReq)
    if err != nil {
        s.logger.Error("Failed to send notification",
            zap.Error(err),
            zap.String("user_id", req.UserId),
        )
        return nil, status.Errorf(codes.Internal, "failed to send notification: %v", err)
    }

    // Convert to protobuf response
    return &pb.SendNotificationResponse{
        NotificationId: notification.ID,
        Status:         pb.NotificationStatus(notification.Status),
    }, nil
}

func (s *Server) GetNotification(
    ctx context.Context,
    req *pb.GetNotificationRequest,
) (*pb.Notification, error) {
    notification, err := s.service.GetNotification(ctx, req.NotificationId)
    if err != nil {
        if err == domain.ErrNotFound {
            return nil, status.Errorf(codes.NotFound, "notification not found")
        }
        return nil, status.Errorf(codes.Internal, "failed to get notification: %v", err)
    }

    return convertToPBNotification(notification), nil
}

func (s *Server) ListNotifications(
    ctx context.Context,
    req *pb.ListNotificationsRequest,
) (*pb.ListNotificationsResponse, error) {
    domainReq := &domain.ListNotificationsRequest{
        UserID:    req.UserId,
        PageSize:  int(req.PageSize),
        PageToken: req.PageToken,
        Status:    domain.NotificationStatus(req.Status),
        Type:      domain.NotificationType(req.Type),
    }

    notifications, nextToken, err := s.service.ListNotifications(ctx, domainReq)
    if err != nil {
        return nil, status.Errorf(codes.Internal, "failed to list notifications: %v", err)
    }

    pbNotifications := make([]*pb.Notification, len(notifications))
    for i, n := range notifications {
        pbNotifications[i] = convertToPBNotification(n)
    }

    return &pb.ListNotificationsResponse{
        Notifications:  pbNotifications,
        NextPageToken:  nextToken,
        TotalCount:     int32(len(notifications)),
    }, nil
}

func (s *Server) MarkAsRead(
    ctx context.Context,
    req *pb.MarkAsReadRequest,
) (*pb.MarkAsReadResponse, error) {
    err := s.service.MarkAsRead(ctx, req.NotificationId, req.UserId)
    if err != nil {
        if err == domain.ErrNotFound {
            return nil, status.Errorf(codes.NotFound, "notification not found")
        }
        if err == domain.ErrUnauthorized {
            return nil, status.Errorf(codes.PermissionDenied, "unauthorized")
        }
        return nil, status.Errorf(codes.Internal, "failed to mark as read: %v", err)
    }

    return &pb.MarkAsReadResponse{Success: true}, nil
}

func (s *Server) StreamNotifications(
    req *pb.StreamNotificationsRequest,
    stream pb.NotificationService_StreamNotificationsServer,
) error {
    // Implementation for streaming notifications
    // This would typically involve a message queue or pub/sub system
    // Simplified for example
    return status.Errorf(codes.Unimplemented, "streaming not yet implemented")
}

func convertToPBNotification(n *domain.Notification) *pb.Notification {
    pbNotif := &pb.Notification{
        Id:        n.ID,
        UserId:    n.UserID,
        Type:      pb.NotificationType(n.Type),
        Subject:   n.Subject,
        Body:      n.Body,
        Status:    pb.NotificationStatus(n.Status),
        CreatedAt: n.CreatedAt.Unix(),
        Metadata:  n.Metadata,
    }

    if !n.SentAt.IsZero() {
        pbNotif.SentAt = n.SentAt.Unix()
    }

    if n.ReadAt != nil {
        pbNotif.ReadAt = n.ReadAt.Unix()
    }

    return pbNotif
}

func validateSendRequest(req *pb.SendNotificationRequest) error {
    if req.UserId == "" {
        return fmt.Errorf("user_id is required")
    }
    if req.Subject == "" {
        return fmt.Errorf("subject is required")
    }
    if req.Body == "" {
        return fmt.Errorf("body is required")
    }
    return nil
}

Example 2: Service Communication

Services communicate via multiple patterns:

1. Synchronous gRPC for Request/Response

// Task service calling notification service
package task

import (
    "context"
    "time"

    notificationpb "github.com/taskman/notifications-service/pkg/pb"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
)

type NotificationClient struct {
    client notificationpb.NotificationServiceClient
}

func NewNotificationClient(address string) (*NotificationClient, error) {
    conn, err := grpc.Dial(
        address,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithTimeout(5*time.Second),
    )
    if err != nil {
        return nil, err
    }

    return &NotificationClient{
        client: notificationpb.NewNotificationServiceClient(conn),
    }, nil
}

func (nc *NotificationClient) NotifyTaskAssignment(ctx context.Context, task *Task, assigneeID string) error {
    req := &notificationpb.SendNotificationRequest{
        UserId:   assigneeID,
        Type:     notificationpb.NotificationType_EMAIL,
        Subject:  fmt.Sprintf("Task Assigned: %s", task.Title),
        Body:     fmt.Sprintf("You have been assigned task: %s", task.Description),
        Priority: notificationpb.Priority_NORMAL,
        Metadata: map[string]string{
            "task_id":    task.ID,
            "task_title": task.Title,
        },
    }

    _, err := nc.client.SendNotification(ctx, req)
    return err
}

2. Asynchronous Events via NATS

// Event publishing from task service
package task

import (
    "encoding/json"
    "time"

    "github.com/nats-io/nats.go"
    "go.uber.org/zap"
)

type EventPublisher struct {
    nc     *nats.Conn
    logger *zap.Logger
}

func NewEventPublisher(natsURL string, logger *zap.Logger) (*EventPublisher, error) {
    nc, err := nats.Connect(natsURL)
    if err != nil {
        return nil, err
    }

    return &EventPublisher{
        nc:     nc,
        logger: logger,
    }, nil
}

type TaskCreatedEvent struct {
    TaskID      string    `json:"task_id"`
    UserID      string    `json:"user_id"`
    Title       string    `json:"title"`
    AssignedTo  string    `json:"assigned_to"`
    CreatedAt   time.Time `json:"created_at"`
    EventType   string    `json:"event_type"`
    EventTime   time.Time `json:"event_time"`
}

func (ep *EventPublisher) PublishTaskCreated(task *Task) error {
    event := TaskCreatedEvent{
        TaskID:     task.ID,
        UserID:     task.UserID,
        Title:      task.Title,
        AssignedTo: task.AssignedTo,
        CreatedAt:  task.CreatedAt,
        EventType:  "task.created",
        EventTime:  time.Now(),
    }

    data, err := json.Marshal(event)
    if err != nil {
        ep.logger.Error("Failed to marshal event", zap.Error(err))
        return err
    }

    // Publish to NATS subject
    if err := ep.nc.Publish("tasks.events.created", data); err != nil {
        ep.logger.Error("Failed to publish event", zap.Error(err))
        return err
    }

    ep.logger.Info("Published task created event",
        zap.String("task_id", task.ID),
        zap.String("event_type", event.EventType),
    )

    return nil
}

// Event subscription in notification service
package notification

import (
    "encoding/json"

    "github.com/nats-io/nats.go"
    "go.uber.org/zap"
)

type EventSubscriber struct {
    nc      *nats.Conn
    service *NotificationService
    logger  *zap.Logger
}

func NewEventSubscriber(natsURL string, service *NotificationService, logger *zap.Logger) (*EventSubscriber, error) {
    nc, err := nats.Connect(natsURL)
    if err != nil {
        return nil, err
    }

    es := &EventSubscriber{
        nc:      nc,
        service: service,
        logger:  logger,
    }

    // Subscribe to task events
    if _, err := nc.Subscribe("tasks.events.>", es.handleTaskEvent); err != nil {
        return nil, err
    }

    logger.Info("Subscribed to task events")
    return es, nil
}

func (es *EventSubscriber) handleTaskEvent(msg *nats.Msg) {
    var event map[string]interface{}
    if err := json.Unmarshal(msg.Data, &event); err != nil {
        es.logger.Error("Failed to unmarshal event", zap.Error(err))
        return
    }

    eventType, ok := event["event_type"].(string)
    if !ok {
        es.logger.Error("Event missing event_type")
        return
    }

    switch eventType {
    case "task.created":
        es.handleTaskCreated(event)
    case "task.assigned":
        es.handleTaskAssigned(event)
    case "task.completed":
        es.handleTaskCompleted(event)
    default:
        es.logger.Warn("Unknown event type", zap.String("event_type", eventType))
    }
}

func (es *EventSubscriber) handleTaskCreated(event map[string]interface{}) {
    // Extract event data and send notification
    taskID, _ := event["task_id"].(string)
    userID, _ := event["user_id"].(string)
    title, _ := event["title"].(string)

    es.logger.Info("Handling task created event",
        zap.String("task_id", taskID),
        zap.String("user_id", userID),
    )

    // Send notification
    // Implementation details...
}

Data Management

Database Per Service

Each service has its own database:

# PostgreSQL databases
- taskman_tasks          # Task service
- taskman_workflows      # Workflow service
- taskman_notifications  # Notification service
- taskman_auth           # Auth service
- taskman_reports        # Reports service (read replica)

Data Migration Strategy

Migrating data from monolith to microservices:

// Data migration script
package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    _ "github.com/lib/pq"
)

func migrateNotificationData() error {
    // Source: Monolith database
    srcDB, err := sql.Open("postgres", "postgresql://monolith-db:5432/taskman")
    if err != nil {
        return err
    }
    defer srcDB.Close()

    // Destination: Notifications service database
    dstDB, err := sql.Open("postgres", "postgresql://notifications-db:5432/taskman_notifications")
    if err != nil {
        return err
    }
    defer dstDB.Close()

    // Migrate in batches
    batchSize := 1000
    offset := 0

    for {
        // Read batch from monolith
        rows, err := srcDB.Query(`
            SELECT id, user_id, type, subject, body, status,
                   created_at, sent_at, read_at, metadata
            FROM notifications
            ORDER BY id
            LIMIT $1 OFFSET $2
        `, batchSize, offset)
        if err != nil {
            return err
        }

        notifications := make([]Notification, 0, batchSize)
        for rows.Next() {
            var n Notification
            var metadata sql.NullString
            var sentAt, readAt sql.NullTime

            err := rows.Scan(
                &n.ID, &n.UserID, &n.Type, &n.Subject, &n.Body, &n.Status,
                &n.CreatedAt, &sentAt, &readAt, &metadata,
            )
            if err != nil {
                rows.Close()
                return err
            }

            if sentAt.Valid {
                n.SentAt = sentAt.Time
            }
            if readAt.Valid {
                n.ReadAt = &readAt.Time
            }
            if metadata.Valid {
                // Parse JSON metadata
                // ...
            }

            notifications = append(notifications, n)
        }
        rows.Close()

        if len(notifications) == 0 {
            break // Migration complete
        }

        // Write batch to notifications service DB
        tx, err := dstDB.Begin()
        if err != nil {
            return err
        }

        stmt, err := tx.Prepare(`
            INSERT INTO notifications
            (id, user_id, type, subject, body, status, created_at, sent_at, read_at, metadata)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
            ON CONFLICT (id) DO NOTHING
        `)
        if err != nil {
            tx.Rollback()
            return err
        }

        for _, n := range notifications {
            _, err := stmt.Exec(
                n.ID, n.UserID, n.Type, n.Subject, n.Body, n.Status,
                n.CreatedAt, n.SentAt, n.ReadAt, n.Metadata,
            )
            if err != nil {
                stmt.Close()
                tx.Rollback()
                return err
            }
        }

        stmt.Close()
        if err := tx.Commit(); err != nil {
            return err
        }

        log.Printf("Migrated %d notifications (offset: %d)", len(notifications), offset)
        offset += batchSize
        time.Sleep(100 * time.Millisecond) // Rate limiting
    }

    log.Println("Notification data migration complete")
    return nil
}

Handling Distributed Transactions

Using the Saga pattern for distributed transactions:

// Saga coordinator for task creation workflow
package saga

import (
    "context"
    "fmt"

    "go.uber.org/zap"
)

type CreateTaskSaga struct {
    taskService         *task.Service
    notificationService *notification.Client
    workflowService     *workflow.Client
    logger              *zap.Logger
}

type CreateTaskRequest struct {
    UserID      string
    Title       string
    Description string
    AssignedTo  string
}

func (s *CreateTaskSaga) Execute(ctx context.Context, req *CreateTaskRequest) error {
    // Step 1: Create task
    task, err := s.taskService.CreateTask(ctx, &task.CreateRequest{
        UserID:      req.UserID,
        Title:       req.Title,
        Description: req.Description,
        AssignedTo:  req.AssignedTo,
    })
    if err != nil {
        s.logger.Error("Failed to create task", zap.Error(err))
        return err
    }

    // Step 2: Notify assignee
    if err := s.notificationService.NotifyTaskAssignment(ctx, task.ID, req.AssignedTo); err != nil {
        // Compensating action: Delete task
        s.logger.Error("Failed to send notification, rolling back", zap.Error(err))
        if deleteErr := s.taskService.DeleteTask(ctx, task.ID); deleteErr != nil {
            s.logger.Error("Failed to delete task during rollback", zap.Error(deleteErr))
        }
        return fmt.Errorf("saga failed at notification step: %w", err)
    }

    // Step 3: Start workflow if applicable
    if task.WorkflowID != "" {
        if err := s.workflowService.StartExecution(ctx, task.WorkflowID, task.ID); err != nil {
            // Compensating actions: Delete notification and task
            s.logger.Error("Failed to start workflow, rolling back", zap.Error(err))

            // Best effort cleanup
            go func() {
                // Cancel notification
                // Delete task
                // Log failures
            }()

            return fmt.Errorf("saga failed at workflow step: %w", err)
        }
    }

    s.logger.Info("Task creation saga completed successfully",
        zap.String("task_id", task.ID),
    )

    return nil
}

Deployment and Operations

Kubernetes Deployment

# notifications-service/deployments/kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notifications-service
  namespace: taskman
  labels:
    app: notifications-service
    version: v1.2.0
spec:
  replicas: 3
  selector:
    matchLabels:
      app: notifications-service
  template:
    metadata:
      labels:
        app: notifications-service
        version: v1.2.0
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9090"
        prometheus.io/path: "/metrics"
    spec:
      serviceAccountName: notifications-service

      containers:
      - name: notifications-service
        image: taskman/notifications-service:v1.2.0
        ports:
        - containerPort: 8080
          name: grpc
          protocol: TCP
        - containerPort: 8081
          name: http
          protocol: TCP
        - containerPort: 9090
          name: metrics
          protocol: TCP

        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: notifications-db-credentials
              key: url
        - name: NATS_URL
          value: "nats://nats.taskman.svc.cluster.local:4222"
        - name: SMTP_HOST
          valueFrom:
            configMapKeyRef:
              name: notifications-config
              key: smtp-host
        - name: LOG_LEVEL
          value: "info"

        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "500m"
            memory: "512Mi"

        livenessProbe:
          httpGet:
            path: /health
            port: 8081
          initialDelaySeconds: 10
          periodSeconds: 10

        readinessProbe:
          httpGet:
            path: /ready
            port: 8081
          initialDelaySeconds: 5
          periodSeconds: 5

      - name: cloudsql-proxy
        image: gcr.io/cloudsql-docker/gce-proxy:latest
        command:
          - "/cloud_sql_proxy"
          - "-instances=project:region:taskman-notifications-db=tcp:5432"
        resources:
          requests:
            cpu: "50m"
            memory: "64Mi"

---
apiVersion: v1
kind: Service
metadata:
  name: notifications-service
  namespace: taskman
spec:
  selector:
    app: notifications-service
  ports:
  - name: grpc
    port: 8080
    targetPort: 8080
  - name: http
    port: 8081
    targetPort: 8081
  type: ClusterIP

---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: notifications-service-hpa
  namespace: taskman
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: notifications-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: grpc_requests_per_second
      target:
        type: AverageValue
        averageValue: "1000"

Service Mesh with Istio

# Istio VirtualService for traffic management
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: notifications-service
  namespace: taskman
spec:
  hosts:
  - notifications-service
  http:
  - match:
    - headers:
        version:
          exact: canary
    route:
    - destination:
        host: notifications-service
        subset: v1-2-0
      weight: 10
    - destination:
        host: notifications-service
        subset: v1-1-0
      weight: 90
  - route:
    - destination:
        host: notifications-service
        subset: v1-1-0

---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: notifications-service
  namespace: taskman
spec:
  host: notifications-service
  trafficPolicy:
    connectionPool:
      tcp:
        maxConnections: 100
      http:
        http1MaxPendingRequests: 50
        http2MaxRequests: 100
        maxRequestsPerConnection: 2
    loadBalancer:
      simple: LEAST_REQUEST
    outlierDetection:
      consecutiveErrors: 5
      interval: 30s
      baseEjectionTime: 30s
      maxEjectionPercent: 50
      minHealthPercent: 50
  subsets:
  - name: v1-1-0
    labels:
      version: v1.1.0
  - name: v1-2-0
    labels:
      version: v1.2.0

Monitoring and Observability

Distributed Tracing

// Implementing OpenTelemetry tracing
package main

import (
    "context"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/jaeger"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
    "go.opentelemetry.io/otel/trace"
)

func initTracer(serviceName string) (func(), error) {
    exporter, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://jaeger:14268/api/traces")))
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String(serviceName),
            attribute.String("environment", "production"),
        )),
    )

    otel.SetTracerProvider(tp)

    return func() {
        if err := tp.Shutdown(context.Background()); err != nil {
            log.Printf("Error shutting down tracer provider: %v", err)
        }
    }, nil
}

// Using tracing in service methods
func (s *NotificationService) SendNotification(
    ctx context.Context,
    req *SendNotificationRequest,
) (*Notification, error) {
    tracer := otel.Tracer("notifications-service")
    ctx, span := tracer.Start(ctx, "SendNotification")
    defer span.End()

    span.SetAttributes(
        attribute.String("user_id", req.UserID),
        attribute.String("notification_type", string(req.Type)),
        attribute.String("priority", string(req.Priority)),
    )

    // Create notification
    notification, err := s.createNotification(ctx, req)
    if err != nil {
        span.RecordError(err)
        return nil, err
    }

    span.AddEvent("Notification created", trace.WithAttributes(
        attribute.String("notification_id", notification.ID),
    ))

    // Send notification
    if err := s.sendNotification(ctx, notification); err != nil {
        span.RecordError(err)
        return nil, err
    }

    span.SetAttributes(attribute.String("notification_id", notification.ID))
    return notification, nil
}

Results and Metrics

After 9 months of migration:

Performance Improvements

Metric                    Before (Monolith)    After (Microservices)
------------------------------------------------------------------------
Deployment Time           15-20 minutes        2-5 minutes
Build Time                8 minutes            1-2 minutes per service
Test Suite Duration       45 minutes           5-10 minutes per service
P95 API Latency           850ms                320ms
Database Connections      500 (single pool)    ~100 per service
Scaling Time              10 minutes           30 seconds

Operational Improvements

  • Independent deployments: Each service deploys independently
  • Selective scaling: Scale only the services that need it
  • Faster development: Teams work independently on separate services
  • Better resource utilization: 40% reduction in infrastructure costs
  • Improved reliability: Failures isolated to single services

Challenges Faced

  1. Increased operational complexity: 7 services vs 1 monolith
  2. Distributed debugging: Tracing across services required new tools
  3. Data consistency: Eventual consistency required application changes
  4. Network overhead: Inter-service communication added latency
  5. DevOps burden: More deployment pipelines, more monitoring

Lessons Learned

1. Start with the Right Service

Our first extraction (notifications) was perfect - low coupling, clear boundaries. Starting with a complex, highly-coupled service would have been disastrous.

2. Data Separation is Hard

Migrating data from a shared database to service-specific databases was our biggest challenge. Plan for:

  • Dual writes during transition
  • Data reconciliation
  • Rollback strategies

3. Invest in Observability Early

Distributed tracing, centralized logging, and comprehensive metrics are not optional. Implement before you need them.

4. Communication Patterns Matter

Choose the right pattern for each use case:

  • gRPC: Request/response, low latency required
  • Events: Async, eventual consistency acceptable
  • REST: External APIs, third-party integrations

5. Not Everything Should be a Microservice

We kept the auth service in the monolith until the end because it was stable, low-change, and deeply integrated. Sometimes the monolith is the right answer.

Conclusion

The migration from monolith to microservices was a significant undertaking that fundamentally changed how we build and deploy software. The benefits - independent scaling, faster deployments, team autonomy - were worth the investment, but the journey required careful planning, robust tooling, and organizational commitment.

Key takeaways:

  1. Start small: Extract one service, learn, iterate
  2. Define clear boundaries: Use DDD principles
  3. Invest in infrastructure: Service mesh, observability, automation
  4. Plan data migration carefully: This is the hardest part
  5. Don’t rush: Nine months was appropriate for our scale

For teams considering a similar migration, the most important question is: Why? If you can’t articulate specific problems that microservices will solve, don’t migrate. But if you’re hitting scaling, deployment, or organizational bottlenecks, microservices can be transformative.

Additional Resources

For consultation on microservices architecture and Go development, contact mmattox@support.tools.