Service-Based Architecture #

Between a monolith that is too large for one team and microservices that are too complex for an unprepared team, there is a frequently overlooked space: Service-Based Architecture (SBA). This is not a half-hearted compromise — it is a deliberate, pragmatic decision. The system is split into 4 to 12 services larger than microservices, usually still sharing one database, communicating through simple APIs, and deployable separately. The result: different teams can work on different services without constantly disrupting each other, deployments become more granular, and the codebase is easier to understand — without bearing the full overhead of a distributed system. Many successful business systems operate for their entire lifetime as SBA and never need to evolve further.

Position in the Architecture Spectrum #

Service-Based Architecture occupies a clear position among several choices:

flowchart LR
    M["Monolith\\n1 deployment\\n1 codebase\\n1 DB"] -->|"+ deployment granularity"| SBA
    SBA["Service-Based\\n4–12 services\\ncan deploy separately\\nshared DB"] -->|"+ DB isolation\\n+ full autonomy"| MS["Microservices\\nN services\\nN DBs\\nfull isolation"]

    MM["Modular Monolith\\n1 deployment\\nfirm module boundaries\\n1 DB"] -->|"+ separate deployment"| SBA
DimensionMonolithService-BasedMicroservices
Deployment units14–1210–100+
Database1 shared1 shared (or partial)1 per service
CommunicationIn-processHTTP/REST between servicesHTTP/gRPC + events
Team autonomyLowModerateHigh
Ops complexityVery lowLow–moderateHigh
TransactionsFull ACIDFull ACID (shared DB)Eventual consistency
Best for teams1–105–3020+

The critical, often-overlooked advantage of SBA is the ability to use database transactions across tables owned by different services — something impossible in microservices without a complex saga pattern.


Key Characteristics of SBA #

flowchart TD
    subgraph SBA["Service-Based Architecture"]
        GW["API Gateway / Load Balancer"]

        subgraph US["User Service\\n:8081"]
            UH["Handler"] --> USVC["Service"] --> UREPO["Repository"]
        end

        subgraph OS["Order Service\\n:8082"]
            OH["Handler"] --> OSVC["Service"] --> OREPO["Repository"]
        end

        subgraph PS["Payment Service\\n:8083"]
            PH["Handler"] --> PSVC["Service"] --> PREPO["Repository"]
        end

        subgraph NS["Notification Service\\n:8084"]
            NH["Handler"] --> NSVC["Service"] --> NREPO["Repository"]
        end

        DB[(Shared Database\\nPostgreSQL)]

        GW --> US & OS & PS & NS
        UREPO & OREPO & PREPO & NREPO --> DB
    end

Three characteristics define SBA:

Larger service granularity — each service covers a business domain large enough to stand alone as a team’s work unit. Not one function = one service (microservices), but one domain = one service.

A shared database is the norm, not the exception — unlike microservices where a shared DB is an anti-pattern, in SBA it is a deliberate choice to gain transactional ease.

Separate deployment, not separate runtime — each service has its own CI/CD pipeline and build artifact, but they may run on the same infrastructure.


Managing the Shared Database Correctly #

The shared database is both SBA’s greatest strength and greatest risk. The biggest risk is when services start directly accessing tables owned by other services — this is the road to a distributed monolith.

// ✓ Each service only accesses tables it "owns"
// Table ownership is defined explicitly

// User Service: owns the users, user_profiles, user_sessions tables
// Order Service: owns the orders, order_items, order_status_history tables
// Payment Service: owns the payments, payment_methods, refunds tables
// Notification Service: owns the notifications, notification_templates tables

// user-service/internal/repository/user_repo.go
package repository

// UserRepo only accesses tables owned by the User Service
type UserRepo struct {
	db *sql.DB
}

func (r *UserRepo) FindByID(ctx context.Context, id string) (*User, error) {
	var u User
	err := r.db.QueryRowContext(ctx,
		`SELECT id, full_name, email, is_active FROM users WHERE id = $1`, id,
	).Scan(&u.ID, &u.FullName, &u.Email, &u.IsActive)
	return &u, err
}

// ✗ DON'T: the User Service accessing the orders table
func (r *UserRepo) GetUserOrders(ctx context.Context, userID string) ([]Order, error) {
	// ✗ This is wrong coupling — the User Service does not own the orders table
	rows, _ := r.db.QueryContext(ctx,
		`SELECT id, total FROM orders WHERE user_id = $1`, userID,
	)
	_ = rows
	return nil, nil
}
// Table ownership strategy: define it explicitly in documentation and code

// db/ownership.go — a dedicated package documenting table ownership
package db

// TableOwnership defines which service "owns" each table
// Other services must not write to these tables, and ideally should not read them directly
var TableOwnership = map[string]string{
	"users":                   "user-service",
	"user_profiles":           "user-service",
	"user_sessions":           "user-service",
	"orders":                  "order-service",
	"order_items":             "order-service",
	"order_status_history":    "order-service",
	"payments":                "payment-service",
	"payment_methods":         "payment-service",
	"refunds":                 "payment-service",
	"notifications":           "notification-service",
	"notification_templates":  "notification-service",
}

Implementation: A Consistent Service Structure #

Each service in SBA should have a consistent internal structure — this makes it easy for developers to move between services:

// order-service/main.go
package main

import (
	"database/sql"
	"log"
	"net/http"
	"os"

	_ "github.com/lib/pq"
)

func main() {
	// Read config from environment variables — each service has its own config
	dbURL := os.Getenv("DATABASE_URL")
	userServiceURL := os.Getenv("USER_SERVICE_URL")
	port := os.Getenv("PORT")
	if port == "" {
		port = "8082"
	}

	db, err := sql.Open("postgres", dbURL)
	if err != nil {
		log.Fatal("db connection failed:", err)
	}
	defer db.Close()

	// Initialize layers — the same as a monolith but per service
	repo := repository.NewOrderRepository(db)
	userClient := client.NewUserServiceClient(userServiceURL)
	svc := service.NewOrderService(repo, userClient)
	handler := handler.NewOrderHandler(svc)

	mux := http.NewServeMux()
	handler.RegisterRoutes(mux)

	log.Printf("Order Service running on :%s", port)
	log.Fatal(http.ListenAndServe(":"+port, mux))
}
// order-service/internal/service/order_service.go
package service

import (
	"context"
	"errors"
	"time"
)

// UserClient is the interface for calling the User Service
// ✓ Each service defines interfaces for its dependencies
type UserClient interface {
	ValidateActive(ctx context.Context, userID string) error
	GetName(ctx context.Context, userID string) (string, error)
}

// OrderRepository is the data access interface
type OrderRepository interface {
	Save(ctx context.Context, order *Order) error
	FindByID(ctx context.Context, id string) (*Order, error)
	FindByUserID(ctx context.Context, userID string) ([]*Order, error)
	UpdateStatus(ctx context.Context, id string, status OrderStatus) error
}

type OrderService struct {
	repo       OrderRepository
	userClient UserClient
}

func NewOrderService(repo OrderRepository, userClient UserClient) *OrderService {
	return &OrderService{repo: repo, userClient: userClient}
}

// PlaceOrder is the main use case of the Order Service
func (s *OrderService) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*OrderOutput, error) {
	// Validate the user via the User Service — inter-service communication
	if err := s.userClient.ValidateActive(ctx, input.UserID); err != nil {
		return nil, errors.New("invalid user: " + err.Error())
	}

	total := int64(0)
	items := make([]OrderItem, len(input.Items))
	for i, item := range input.Items {
		items[i] = OrderItem{
			ProductID:  item.ProductID,
			Quantity:   item.Quantity,
			PriceCents: item.PriceCents,
		}
		total += item.PriceCents * int64(item.Quantity)
	}

	order := &Order{
		ID:         generateID(),
		UserID:     input.UserID,
		Status:     StatusPending,
		Items:      items,
		TotalCents: total,
		CreatedAt:  time.Now(),
	}

	// Save to the shared database — because it is a shared DB, ACID is possible
	if err := s.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	return &OrderOutput{
		OrderID:    order.ID,
		Status:     string(order.Status),
		TotalCents: order.TotalCents,
	}, nil
}
// order-service/internal/client/user_client.go
// Client for calling the User Service via HTTP
package client

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type UserServiceClient struct {
	baseURL    string
	httpClient *http.Client
}

func NewUserServiceClient(baseURL string) *UserServiceClient {
	return &UserServiceClient{
		baseURL: baseURL,
		httpClient: &http.Client{
			Timeout: 3 * time.Second, // ✓ always a timeout
		},
	}
}

type userResponse struct {
	ID       string `json:"id"`
	FullName string `json:"full_name"`
	IsActive bool   `json:"is_active"`
}

func (c *UserServiceClient) ValidateActive(ctx context.Context, userID string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		fmt.Sprintf("%s/users/%s", c.baseURL, userID), nil)
	if err != nil {
		return err
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("user service unavailable: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusNotFound {
		return fmt.Errorf("user %s not found", userID)
	}
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("user service error: status %d", resp.StatusCode)
	}

	var user userResponse
	if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
		return err
	}

	if !user.IsActive {
		return fmt.Errorf("user %s is inactive", userID)
	}
	return nil
}

func (c *UserServiceClient) GetName(ctx context.Context, userID string) (string, error) {
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
		fmt.Sprintf("%s/users/%s", c.baseURL, userID), nil)
	resp, err := c.httpClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var user userResponse
	json.NewDecoder(resp.Body).Decode(&user)
	return user.FullName, nil
}

Monorepo vs Polyrepo Structure #

SBA can use a monorepo (all services in one repository) or polyrepo (each service has its own repository):

flowchart LR
    subgraph MONO["Monorepo — all services in one repo"]
        R["root/"]
        R --> US2["user-service/"]
        R --> OS2["order-service/"]
        R --> PS2["payment-service/"]
        R --> NS2["notification-service/"]
        R --> DB2["db/migrations/"]
        R --> LIB2["shared/\\n(common library)"]
    end

    subgraph POLY["Polyrepo — each service has its own repo"]
        R1["user-service/\\n(separate repo)"]
        R2["order-service/\\n(separate repo)"]
        R3["payment-service/\\n(separate repo)"]
        R4["shared-lib/\\n(separate Go module)"]
    end
AspectMonorepoPolyrepo
Cross-service refactorsEasy — one PRHard — multi-PR coordination
CI/CDMore complex — needs change detectionSimpler per service
Shared codeEasy — direct importsNeeds Go module versioning
Team independenceLowerHigher
Best forTeams that often refactor across servicesTeams wanting full independence

For SBA with small to medium teams, a monorepo is often more productive — cross-service changes can be done in one PR, database migrations can be managed together, and shared utilities can be imported directly.


Leveraging the Shared Database: Cross-Table Transactions #

SBA’s most practical advantage over microservices is the ability to run ACID transactions spanning tables “owned” by different services — when the business needs it:

// order-service/internal/repository/order_repo.go
// Example: PlaceOrder + UpdateInventory in one ACID transaction
// This is IMPOSSIBLE in microservices without a complex saga

func (r *OrderRepository) PlaceOrderWithInventory(
	ctx context.Context,
	order *Order,
	productID string,
	quantity int,
) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer tx.Rollback()

	// Insert the order
	_, err = tx.ExecContext(ctx,
		`INSERT INTO orders (id, user_id, status, total_cents, created_at)
		 VALUES ($1, $2, $3, $4, NOW())`,
		order.ID, order.UserID, order.Status, order.TotalCents,
	)
	if err != nil {
		return fmt.Errorf("failed to insert order: %w", err)
	}

	// Update inventory — a table owned by the "inventory domain" but in the same DB
	// ✓ In SBA this is valid — one DB, one transaction
	result, err := tx.ExecContext(ctx,
		`UPDATE products SET stock = stock - $1
		 WHERE id = $2 AND stock >= $1`,
		quantity, productID,
	)
	if err != nil {
		return fmt.Errorf("failed to update inventory: %w", err)
	}

	rows, _ := result.RowsAffected()
	if rows == 0 {
		return errors.New("insufficient stock")
	}

	return tx.Commit()
}
Use shared transactions wisely. Although valid in SBA, overly deep cross-table dependencies in transactions will make future service extraction harder. Document every cross-service transaction and consider whether it can be replaced with eventual consistency as the system evolves.

Deployment: Separate but Coordinated #

# docker-compose.yml — example SBA deployment for development
version: '3.8'

services:
  # Shared database — one DB for all services
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"

  # User Service — separate deployment
  user-service:
    build: ./user-service
    ports:
      - "8081:8081"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      PORT: "8081"
    depends_on:
      - postgres

  # Order Service — separate deployment, knows the User Service URL
  order-service:
    build: ./order-service
    ports:
      - "8082:8082"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      USER_SERVICE_URL: http://user-service:8081
      PORT: "8082"
    depends_on:
      - postgres
      - user-service

  # Payment Service
  payment-service:
    build: ./payment-service
    ports:
      - "8083:8083"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      ORDER_SERVICE_URL: http://order-service:8082
      PORT: "8083"
    depends_on:
      - postgres
      - order-service

  # Notification Service
  notification-service:
    build: ./notification-service
    ports:
      - "8084:8084"
    environment:
      DATABASE_URL: postgres://app:secret@postgres/appdb
      PORT: "8084"
    depends_on:
      - postgres

  # API Gateway — routes all requests to the right service
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - user-service
      - order-service
      - payment-service

SBA vs Microservices: When Enough Is Enough, When to Evolve #

flowchart TD
    Q1{Is there a concrete\\nbottleneck?} -->|No| SBA_OK["SBA is enough\\n✓ Keep it"]
    Q1 -->|Yes| Q2{Where is the bottleneck?}

    Q2 -->|"Scaling: one service\\nis a resource bottleneck"| MS1["Extract that service\\ninto a microservice with its own DB"]
    Q2 -->|"Deployment: one service\\nblocks another"| MS2["Split CI/CD pipelines\\n(already present in SBA)"]
    Q2 -->|"Database: shared DB\\nhas become a bottleneck"| MS3["Start separating schemas\\nper service\\n→ toward microservices"]
    Q2 -->|"Team: too large\\nfor one codebase"| MS4["Consider polyrepo\\nand full microservices"]
SBA remains the right choice while:
  ✓ Teams of 5–30 developers with clear domain ownership
  ✓ No single service needs 10x more resources than the others
  ✓ The shared database is not yet a bottleneck (tunable with indexes and connection pools)
  ✓ ACID transactions across business domains are still regularly needed
  ✓ The operational cost of microservices does not justify the benefit

Consider evolving to full microservices if:
  ✗ The shared database has become a bottleneck that cannot be solved
  ✗ One service needs incompatible technology (Python for ML, etc.)
  ✗ The team is already > 30 developers with full domain ownership
  ✗ One service needs 100x the scaling of the others
  ✗ Regulatory/compliance requires full data isolation

Anti-Patterns to Avoid #

// ✗ A service directly querying another service's database
// order-service accesses tables owned by user-service
func (r *OrderRepo) GetOrderWithUserDetails(ctx context.Context, orderID string) (*OrderDetail, error) {
	// ✗ The ORDER service directly JOINs USER tables — direct database coupling
	row := r.db.QueryRowContext(ctx, `
		SELECT o.id, o.total_cents, u.full_name, u.email
		FROM orders o
		JOIN users u ON u.id = o.user_id  -- ✗ the users table is not owned by order-service
		WHERE o.id = $1
	`, orderID)
	_ = row
	return nil, nil
}

// ✓ Fetch user data via API, not via DB JOINs
func (s *OrderService) GetOrderWithUserDetails(ctx context.Context, orderID string) (*OrderDetail, error) {
	order, err := s.repo.FindByID(ctx, orderID)
	if err != nil {
		return nil, err
	}

	// ✓ Fetch the user data from the User Service via HTTP API
	userName, err := s.userClient.GetName(ctx, order.UserID)
	if err != nil {
		userName = "Unknown" // ✓ graceful degradation
	}

	return &OrderDetail{
		OrderID:    order.ID,
		UserName:   userName,
		TotalCents: order.TotalCents,
	}, nil
}

// ✗ No timeout on inter-service calls — goroutines wait forever
func (c *UserClient) GetUser(ctx context.Context, id string) (*User, error) {
	resp, err := http.Get(c.baseURL + "/users/" + id) // ✗ no timeout!
	_ = resp
	return nil, err
}

// ✓ Always a timeout — either on the context or on the http.Client
func (c *UserClient) GetUser(ctx context.Context, id string) (*User, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // ✓ bounded wait
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/users/"+id, nil)
	resp, err := c.http.Do(req)
	_ = resp
	return nil, err
}

// ✗ Another service writing directly to tables owned by a different service
// notification-service writes directly to the orders table
func (n *NotificationService) MarkOrderNotified(ctx context.Context, orderID string) error {
	// ✗ notification-service must not write to the orders table
	_, err := n.db.ExecContext(ctx,
		`UPDATE orders SET notified = TRUE WHERE id = $1`, orderID,
	)
	return err
}

// ✓ Use an API to ask the owning service to perform the update
func (n *NotificationService) MarkOrderNotified(ctx context.Context, orderID string) error {
	// ✓ Send a request to the Order Service to update the notification status
	return n.orderClient.MarkNotified(ctx, orderID)
}

Service-Based Architecture Review Checklist #

SERVICE BOUNDARIES:
  □ Each service has a clear, non-overlapping business domain
  □ The service count is 4–12 — not too few (= monolith) or too many (= microservices)
  □ Each service has its own CI/CD pipeline
  □ Developers clearly know which tables are "owned" by which service

DATABASE OWNERSHIP:
  □ Table ownership is explicitly documented
  □ No service writes directly to tables owned by another service
  □ Cross-service reads via JOINs are documented and minimized
  □ Existing cross-service transactions are audited periodically

INTER-SERVICE COMMUNICATION:
  □ Every inter-service HTTP call has a timeout
  □ Graceful degradation when another service is unavailable
  □ Interfaces (Go interfaces) are used for dependencies to other services
  □ API contracts are documented (at least a README per service)

SHARED CODE:
  □ Shared utilities live in a separate package (shared/ or common/)
  □ No circular dependencies between services
  □ DTOs shared between services use stable structures

OBSERVABILITY:
  □ Structured logging in all services
  □ Request IDs / trace IDs are propagated across services
  □ Health check endpoints are available on every service
  □ Centralized logging is configured

TESTING:
  □ Unit tests for each service can run without other services (mock clients)
  □ Integration tests exist for the main cross-service flows
  □ go test -race is run regularly

Summary #

  • SBA is a pragmatic bridge between monolith and microservices — not a half-hearted compromise, but a deliberate decision giving deployment granularity without the full overhead of a distributed system.
  • A shared database is a feature, not a bug — the ability to use ACID transactions across domains is a real SBA advantage microservices do not have; use it wisely.
  • Table ownership must be explicitly documented — every table is owned by one service; other services must not write and ideally should not read directly; this prevents a distributed monolith.
  • 4 to 12 services is the sweet spot — below that it is not much different from a monolith; above that coordination overhead rises significantly.
  • Inter-service communication via HTTP clients with timeouts — every call to another service must have a timeout; use Go interfaces for inter-service dependencies.
  • Graceful degradation for non-critical dependencies — if the User Service is unavailable, the Order Service can still show orders with the name “Unknown”; do not fail hard for every dependency.
  • A monorepo is often more productive for small to medium teams — cross-service refactors are easier, database migrations can be managed together, and shared code can be imported directly.
  • Document cross-service transactions — every ACID transaction spanning tables owned by different services is potential technical debt; record it to ease evolution to microservices if needed.
  • SBA can be an end-game, not just a stepping stone — many business systems do not need microservices; a well-managed SBA can serve dozens of developers for years.
  • Evolve gradually when there is real need — extract a shared-DB service into a service with its own DB when there is a concrete bottleneck, not because of industry pressure.

← Previous: Microservice   Next: Event-Driven →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact