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| Dimension | Monolith | Service-Based | Microservices |
|---|---|---|---|
| Deployment units | 1 | 4–12 | 10–100+ |
| Database | 1 shared | 1 shared (or partial) | 1 per service |
| Communication | In-process | HTTP/REST between services | HTTP/gRPC + events |
| Team autonomy | Low | Moderate | High |
| Ops complexity | Very low | Low–moderate | High |
| Transactions | Full ACID | Full ACID (shared DB) | Eventual consistency |
| Best for teams | 1–10 | 5–30 | 20+ |
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
endThree 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| Aspect | Monorepo | Polyrepo |
|---|---|---|
| Cross-service refactors | Easy — one PR | Hard — multi-PR coordination |
| CI/CD | More complex — needs change detection | Simpler per service |
| Shared code | Easy — direct imports | Needs Go module versioning |
| Team independence | Lower | Higher |
| Best for | Teams that often refactor across services | Teams 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.