Modular Monolith Architecture #

Many teams fall into one of two mistakes. The first: building a monolith without structure, letting all modules import each other freely until nobody knows the side effects of any change — this is the so-called big ball of mud. The second: jumping straight to microservices before the domain is well understood, producing a distributed monolith that has all the downsides of distribution without its benefits. Modular Monolith Architecture sits between those two extremes: one deployment, one binary, one pipeline — but with module boundaries as firm as microservice boundaries. Each module has its own domain, service, and repository. Other modules cannot access a module’s internals directly. Communication happens through interfaces or events. The result: all of a monolith’s operational advantages, with a design quality that allows evolution to microservices whenever needed.

What Distinguishes a Modular Monolith from a Traditional Monolith? #

The difference is not in infrastructure — both are one deployment. The difference is in boundary discipline:

flowchart TD
    subgraph TM["Traditional Monolith — Boundaries Not Kept"]
        U1["User code"] -->|"direct import"| O1["Order code"]
        O1 -->|"direct import"| P1["Payment code"]
        P1 -->|"direct import"| U1
        U1 -->|"accesses order DB"| DB1[(One DB\\nwithout boundaries)]
        O1 -->|"accesses user DB"| DB1
    end

    subgraph MM["Modular Monolith — Firm Boundaries"]
        subgraph UM["User Module"]
            UH["Handler"] --> US["Service"] --> UR["Repo"]
            UDB[(user schema)]
            UR --> UDB
        end
        subgraph OM["Order Module"]
            OH["Handler"] --> OS["Service"] --> OR["Repo"]
            ODB[(order schema)]
            OR --> ODB
        end
        subgraph PM["Payment Module"]
            PH["Handler"] --> PS["Service"] --> PR["Repo"]
            PDB[(payment schema)]
            PR --> PDB
        end

        UM -->|"via interface"| OM
        OM -->|"via domain event"| PM
    end

Three pillars make a modular monolith different from a regular monolith:

PillarTraditional MonolithModular Monolith
Module boundariesNone — everything can import everythingFirm — only via public interfaces
Inter-module communicationDirect imports into internalsVia interfaces or domain events
Database schemaOne schema, all tables mixedIsolated schemas per module, no cross-module JOINs

Three Implementation Pillars #

Pillar 1: Each Module Exposes Interfaces, Not Concrete Structs #

A module only exposes interfaces defining what can be done, not how it does it. Other modules may only depend on those interfaces.

// internal/user/api.go — the public API of the User module
// This is the only file other modules are allowed to import
package user

import "context"

// API is the public contract of the User module
// ✓ Other modules depend on this interface, not its implementation
type API interface {
	GetByID(ctx context.Context, id string) (*UserDTO, error)
	ValidateActive(ctx context.Context, id string) error
	GetFullName(ctx context.Context, id string) (string, error)
}

// UserDTO is the DTO shared with other modules
// ✓ Not the internal domain entity — only a data transfer object
type UserDTO struct {
	ID       string
	FullName string
	Email    string
	IsActive bool
}

// Module is the User module entry point exposing its API
type Module struct {
	api     *service  // internal implementation, not exposed
	handler *handler
}

func NewModule(db *sql.DB) *Module {
	repo := newPostgresRepository(db)
	svc := newService(repo)
	h := newHandler(svc)
	return &Module{api: svc, handler: h}
}

// API returns the implementation of the API interface
// ✓ The return type is an interface, not *service
func (m *Module) API() API {
	return m.api
}

// RegisterRoutes registers this module's HTTP routes
func (m *Module) RegisterRoutes(mux *http.ServeMux) {
	mux.HandleFunc("POST /users", m.handler.create)
	mux.HandleFunc("GET /users/{id}", m.handler.getByID)
	mux.HandleFunc("PUT /users/{id}/deactivate", m.handler.deactivate)
}
// internal/user/service.go — internal implementation, NOT exposed to other modules
package user

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

// service implements the API interface
// This is the internal implementation detail of the User module
type service struct {
	repo repository
}

func newService(repo repository) *service {
	return &service{repo: repo}
}

// GetByID implements API.GetByID
func (s *service) GetByID(ctx context.Context, id string) (*UserDTO, error) {
	u, err := s.repo.findByID(ctx, id)
	if err != nil {
		return nil, err
	}
	return &UserDTO{
		ID:       u.id,
		FullName: u.fullName,
		Email:    u.email,
		IsActive: u.isActive,
	}, nil
}

// ValidateActive implements API.ValidateActive
func (s *service) ValidateActive(ctx context.Context, id string) error {
	u, err := s.repo.findByID(ctx, id)
	if err != nil {
		return err
	}
	if !u.isActive {
		return errors.New("user is inactive")
	}
	return nil
}

func (s *service) GetFullName(ctx context.Context, id string) (string, error) {
	u, err := s.repo.findByID(ctx, id)
	if err != nil {
		return "", err
	}
	return u.fullName, nil
}

// user is the INTERNAL domain entity — not exposed to the outside
type user struct {
	id        string
	fullName  string
	email     string
	isActive  bool
	createdAt time.Time
}

func (u *user) deactivate() error {
	if !u.isActive {
		return errors.New("user is already inactive")
	}
	u.isActive = false
	return nil
}

Pillar 2: Inter-Module Communication via an Internal Event Bus #

When module A needs to tell module B that something happened without holding a direct reference to module B, use an internal event bus. This is the strongest form of loose coupling in a modular monolith.

// internal/shared/eventbus/eventbus.go — shared component
package eventbus

import (
	"context"
	"fmt"
	"sync"
)

// Event is the interface for all domain events
type Event interface {
	EventName() string
}

// Handler is the function handling an event
type Handler func(ctx context.Context, event Event) error

// EventBus is an in-process event bus for inter-module communication
type EventBus struct {
	mu       sync.RWMutex
	handlers map[string][]Handler
}

func New() *EventBus {
	return &EventBus{
		handlers: make(map[string][]Handler),
	}
}

// Subscribe registers a handler for a specific event
func (b *EventBus) Subscribe(eventName string, handler Handler) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.handlers[eventName] = append(b.handlers[eventName], handler)
}

// Publish publishes an event to all subscribers
// ✓ The publisher does not know who the subscribers are
func (b *EventBus) Publish(ctx context.Context, event Event) error {
	b.mu.RLock()
	handlers := b.handlers[event.EventName()]
	b.mu.RUnlock()

	var errs []error
	for _, handler := range handlers {
		if err := handler(ctx, event); err != nil {
			errs = append(errs, err)
		}
	}
	if len(errs) > 0 {
		return fmt.Errorf("%d handlers failed to process event %s", len(errs), event.EventName())
	}
	return nil
}
// internal/order/events.go — domain events published by the Order module
package order

import "time"

// OrderPlacedEvent is published when an order is successfully created
// ✓ Other modules (notification, inventory) subscribe to this event
type OrderPlacedEvent struct {
	OrderID    string
	CustomerID string
	TotalCents int64
	Items      []OrderItemEvent
	OccurredAt time.Time
}

type OrderItemEvent struct {
	ProductID string
	Quantity  int
}

func (e OrderPlacedEvent) EventName() string { return "order.placed" }

// OrderCancelledEvent is published when an order is cancelled
type OrderCancelledEvent struct {
	OrderID    string
	CustomerID string
	Reason     string
	OccurredAt time.Time
}

func (e OrderCancelledEvent) EventName() string { return "order.cancelled" }
// internal/order/service.go — the Order module uses the User API and EventBus
package order

import (
	"context"
	"errors"
	"time"

	"myapp/internal/shared/eventbus"
	"myapp/internal/user" // ✓ only imports the user package for its types
)

type Service struct {
	repo     Repository
	userAPI  user.API       // ✓ interface from the User module, not a concrete struct
	eventBus *eventbus.EventBus
}

func NewService(repo Repository, userAPI user.API, bus *eventbus.EventBus) *Service {
	return &Service{repo: repo, userAPI: userAPI, eventBus: bus}
}

type PlaceOrderInput struct {
	CustomerID string
	Items      []PlaceOrderItem
}

type PlaceOrderItem struct {
	ProductID  string
	Quantity   int
	PriceCents int64
}

func (s *Service) PlaceOrder(ctx context.Context, input PlaceOrderInput) (*Order, error) {
	// Validate the customer via the User module API
	if err := s.userAPI.ValidateActive(ctx, input.CustomerID); err != nil {
		return nil, errors.New("invalid customer: " + err.Error())
	}

	// Create the order
	order := &Order{
		ID:         generateID(),
		CustomerID: input.CustomerID,
		Status:     StatusPending,
		CreatedAt:  time.Now(),
	}

	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.Items = items
	order.TotalCents = total

	if err := s.repo.Save(ctx, order); err != nil {
		return nil, err
	}

	// Publish the event — the Notification and Inventory modules will respond
	// ✓ Order does not know who subscribes
	eventItems := make([]OrderItemEvent, len(items))
	for i, item := range items {
		eventItems[i] = OrderItemEvent{ProductID: item.ProductID, Quantity: item.Quantity}
	}
	_ = s.eventBus.Publish(ctx, OrderPlacedEvent{
		OrderID:    order.ID,
		CustomerID: order.CustomerID,
		TotalCents: order.TotalCents,
		Items:      eventItems,
		OccurredAt: time.Now(),
	})

	return order, nil
}
// internal/notification/module.go — the Notification module subscribes to Order events
package notification

import (
	"context"
	"fmt"

	"myapp/internal/order"
	"myapp/internal/shared/eventbus"
	"myapp/internal/user"
)

type Module struct {
	userAPI user.API
	bus     *eventbus.EventBus
}

func NewModule(userAPI user.API, bus *eventbus.EventBus) *Module {
	m := &Module{userAPI: userAPI, bus: bus}
	m.registerHandlers()
	return m
}

// registerHandlers registers all event handlers
// ✓ Notification subscribes to events from other modules without direct coupling
func (m *Module) registerHandlers() {
	m.bus.Subscribe("order.placed", m.handleOrderPlaced)
	m.bus.Subscribe("order.cancelled", m.handleOrderCancelled)
}

func (m *Module) handleOrderPlaced(ctx context.Context, event eventbus.Event) error {
	e, ok := event.(order.OrderPlacedEvent)
	if !ok {
		return fmt.Errorf("unexpected event type")
	}

	// Get the customer's name from the User module
	fullName, err := m.userAPI.GetFullName(ctx, e.CustomerID)
	if err != nil {
		return err
	}

	// Send the notification
	fmt.Printf("[Notification] Order %s created by %s, total: Rp%.2f\n",
		e.OrderID, fullName, float64(e.TotalCents)/100)
	return nil
}

func (m *Module) handleOrderCancelled(ctx context.Context, event eventbus.Event) error {
	e, ok := event.(order.OrderCancelledEvent)
	if !ok {
		return fmt.Errorf("unexpected event type")
	}
	fmt.Printf("[Notification] Order %s cancelled: %s\n", e.OrderID, e.Reason)
	return nil
}

Pillar 3: Per-Module Schema Isolation #

Even though one database is used, each module may only access tables in its own schema. No cross-module JOINs.

-- Migration: each module has its own schema
CREATE SCHEMA IF NOT EXISTS user_module;
CREATE SCHEMA IF NOT EXISTS order_module;
CREATE SCHEMA IF NOT EXISTS payment_module;
CREATE SCHEMA IF NOT EXISTS notification_module;

-- User tables only in the user_module schema
CREATE TABLE user_module.users (
    id UUID PRIMARY KEY,
    full_name VARCHAR(200) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Order tables only in the order_module schema
CREATE TABLE order_module.orders (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL, -- reference to a user, but WITHOUT a foreign key constraint
    status VARCHAR(50) NOT NULL,
    total_cents BIGINT NOT NULL,
    created_at TIMESTAMP DEFAULT NOW()
);

-- ✗ No cross-schema JOINs:
-- SELECT o.*, u.full_name FROM order_module.orders o
-- JOIN user_module.users u ON u.id = o.customer_id  ← DON'T

-- ✓ Data from other modules is fetched via API calls, not JOINs

Enforcing Boundaries with Go Tooling #

Go provides a native mechanism for enforcing module boundaries:

// internal/order/internal/domain/order.go
// ✓ The "internal" sub-package can only be accessed within the order package
// The Go compiler prevents imports from outside
package domain

// Order is an internal module entity — inaccessible from outside the order module
type Order struct {
	id         string
	customerID string
	status     Status
	items      []Item
}
// Verify inter-module dependencies with a Go build tag
// Create file: internal/order/check_deps_test.go

//go:build deps_check

package order_test

import (
	// ✓ Allowed: importing shared packages
	_ "myapp/internal/shared/eventbus"

	// ✓ Allowed: importing another module's public API
	_ "myapp/internal/user"

	// ✗ Not allowed: importing another module's internal packages
	// _ "myapp/internal/user/internal/domain"  ← compiler error
	// _ "myapp/internal/payment/service"       ← compiler error
)

Or use the go-cleanarch tool for automatic validation:

# Install
go install github.com/roblaszczak/go-cleanarch@latest

# Run validation — fails if any dependency violates the rules
go-cleanarch -application myapp/internal

# Integrate into CI/CD
# .github/workflows/ci.yml:
# - name: Check architecture
#   run: go-cleanarch -application ./internal

Full Directory Structure #

myapp/
├── cmd/
│   └── server/
│       └── main.go                  ← Entry point: wiring all modules
│
├── internal/                        ← Internal code, cannot be imported from outside
│   │
│   ├── user/                        ← User module
│   │   ├── api.go                   ← PUBLIC: Module struct, API interface, UserDTO
│   │   ├── handler.go               ← INTERNAL: HTTP handler
│   │   ├── service.go               ← INTERNAL: business logic
│   │   ├── repository.go            ← INTERNAL: data access
│   │   └── internal/                ← SUPER PRIVATE: only within the user package
│   │       └── domain/
│   │           └── user.go          ← Domain entity not exposed at all
│   │
│   ├── order/                       ← Order module
│   │   ├── api.go                   ← PUBLIC: Module struct, API interface
│   │   ├── events.go                ← PUBLIC: Domain events (subscribable by other modules)
│   │   ├── handler.go               ← INTERNAL
│   │   ├── service.go               ← INTERNAL
│   │   └── repository.go            ← INTERNAL
│   │
│   ├── payment/                     ← Payment module
│   │   ├── api.go
│   │   ├── events.go
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   │
│   ├── notification/                ← Notification module
│   │   ├── module.go                ← Subscribes to events from other modules
│   │   └── sender.go
│   │
│   └── shared/                      ← Shared components
│       ├── eventbus/
│       │   └── eventbus.go          ← In-process event bus
│       ├── middleware/
│       │   └── auth.go
│       └── response/
│           └── response.go
│
└── migrations/
    ├── 001_create_user_schema.sql
    ├── 002_create_order_schema.sql
    └── 003_create_payment_schema.sql

Wiring in main.go #

// cmd/server/main.go
package main

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

	_ "github.com/lib/pq"

	"myapp/internal/notification"
	"myapp/internal/order"
	"myapp/internal/payment"
	"myapp/internal/shared/eventbus"
	"myapp/internal/user"
)

func main() {
	db, err := sql.Open("postgres", "postgres://localhost/myapp?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Shared event bus — one for the whole application
	bus := eventbus.New()

	// Initialize modules — each module only receives the dependencies it needs
	userModule := user.NewModule(db)
	orderModule := order.NewModule(db, userModule.API(), bus)
	paymentModule := payment.NewModule(db, orderModule.API(), bus)
	notificationModule := notification.NewModule(userModule.API(), bus)

	// All modules ready — notification has already subscribed to events via its constructor

	// Set up the router
	mux := http.NewServeMux()
	userModule.RegisterRoutes(mux)
	orderModule.RegisterRoutes(mux)
	paymentModule.RegisterRoutes(mux)
	// notification has no HTTP routes — it is only an event subscriber

	_ = notificationModule // keep it in scope so it is not GC'd

	log.Println("Modular monolith running on :8080")
	log.Fatal(http.ListenAndServe(":8080", mux))
}
flowchart TD
    MAIN["main.go\\n(wiring all modules)"]
    UM["User Module\\nAPI: GetByID, ValidateActive"]
    OM["Order Module\\nAPI: GetByID, CancelOrder"]
    PM["Payment Module"]
    NM["Notification Module"]
    BUS["EventBus\\n(shared)"]

    MAIN -->|"NewModule(db)"| UM
    MAIN -->|"NewModule(db, userAPI, bus)"| OM
    MAIN -->|"NewModule(db, orderAPI, bus)"| PM
    MAIN -->|"NewModule(userAPI, bus)"| NM

    OM -->|"userAPI.ValidateActive()"| UM
    OM -->|"Publish(OrderPlacedEvent)"| BUS
    PM -->|"Publish(PaymentProcessedEvent)"| BUS
    BUS -->|"handleOrderPlaced()"| NM
    BUS -->|"handlePaymentProcessed()"| NM

The Evolution Path to Microservices #

One of the biggest advantages of a properly designed modular monolith is the ease of extracting a module into a microservice when needed:

flowchart TD
    subgraph MM["Modular Monolith (now)"]
        UM2["User Module"]
        OM2["Order Module"]
        PM2["Payment Module"]
        NM2["Notification Module"]
        BUS2["EventBus (in-process)"]
        OM2 -->|"via interface"| UM2
        OM2 --> BUS2
        BUS2 --> NM2
    end

    subgraph EXTRACT["Extract Payment into a microservice"]
        UM3["User Module\\n(still in the monolith)"]
        OM3["Order Module\\n(still in the monolith)"]
        NM3["Notification Module\\n(still in the monolith)"]
        BUS3["Kafka\\n(replacing the EventBus)"]
        PS["Payment Service\\n(new microservice)"]

        OM3 -->|"via HTTP/gRPC"| PS
        PS --> BUS3
        BUS3 --> NM3
    end

    MM -->|"1. Isolate Payment into a separate service\\n2. Replace the in-process bus with Kafka\\n3. Replace interface calls with HTTP"| EXTRACT

Safe extraction steps:

1. Verify boundaries — ensure the Payment module does not import other modules
   directly (only via interfaces/events)
2. Create the Payment microservice with an API identical to the old API interface
3. Replace the in-process EventBus with Kafka/RabbitMQ for events involving Payment
4. Replace interface calls to Payment with HTTP/gRPC clients
5. Deploy the Payment service separately
6. Remove the Payment module from the monolith

This process can be done gradually without downtime because the interface and event contracts were clearly defined from the start.


Anti-Patterns to Avoid #

// ✗ Direct imports into another module's internals
package order

import "myapp/internal/user/service" // ✗ importing the user package's internals

func (s *Service) PlaceOrder(customerID string) error {
	// ✗ accessing the user module's internal implementation
	userSvc := service.NewUserService(s.db)
	user, _ := userSvc.FindByID(customerID)
	_ = user
	return nil
}

// ✓ Use the API interface exposed by the module
package order

func (s *Service) PlaceOrder(ctx context.Context, customerID string) error {
	// ✓ only using the public API interface
	if err := s.userAPI.ValidateActive(ctx, customerID); err != nil {
		return err
	}
	return nil
}

// ✗ Cross-schema database JOINs
// repository.go in the order module
func (r *repo) GetOrdersWithUserName(ctx context.Context) ([]OrderWithUser, error) {
	// ✗ JOIN into user_module tables — violates schema isolation
	return r.db.QueryContext(ctx, `
		SELECT o.id, o.total_cents, u.full_name
		FROM order_module.orders o
		JOIN user_module.users u ON u.id = o.customer_id
	`)
}

// ✓ Fetch data from other modules via API, not via JOINs
func (s *Service) GetOrdersWithUserName(ctx context.Context) ([]OrderDisplay, error) {
	orders, err := s.repo.FindAll(ctx)
	if err != nil {
		return nil, err
	}

	result := make([]OrderDisplay, len(orders))
	for i, order := range orders {
		name, _ := s.userAPI.GetFullName(ctx, order.CustomerID) // ✓ via API
		result[i] = OrderDisplay{OrderID: order.ID, CustomerName: name}
	}
	return result, nil
}

// ✗ Shared mutable state in event handlers — race condition
var processedEvents = map[string]bool{} // ✗ shared without a lock

func handleEvent(ctx context.Context, event eventbus.Event) error {
	processedEvents[event.EventName()] = true // ✗ data race
	return nil
}

// ✓ Use sync.Map or avoid shared state in event handlers
var processed sync.Map

func handleEventSafe(ctx context.Context, event eventbus.Event) error {
	processed.Store(event.EventName(), true) // ✓ thread-safe
	return nil
}

When a Modular Monolith Is Enough and When to Evolve #

A Modular Monolith is great if:
  ✓ Teams of 5–30 developers with clear per-module domain ownership
  ✓ The system is still in a growth phase — the domain is being understood
  ✓ Fast deployment time matters more than per-service scaling
  ✓ The team does not yet have the capacity to manage distributed system ops
  ✓ There is no bottleneck requiring independent per-module scaling

Consider extracting a module into a microservice if:
  ✗ One module has far higher load than the others
    and vertical/horizontal scaling is no longer efficient
  ✗ The module's owning team needs to deploy independently
    without waiting for the monolith's release
  ✗ The module uses incompatible technology
    (e.g., ML inference needing a Python runtime)
  ✗ Compliance or security requires full runtime isolation

Modular Monolith Review Checklist #

MODULE BOUNDARIES:
  □ Each module has an api.go file exposing the API interface and public DTOs
  □ No direct imports into another module's internal packages
  □ Internal structs and domain entities are not exposed outside the module
  □ The compiler can validate boundaries (internal/ package)

INTER-MODULE COMMUNICATION:
  □ Modules communicate synchronously via API interfaces (not concrete structs)
  □ Modules communicate asynchronously via the EventBus (not direct method calls)
  □ Shared DTOs are data transfer objects, not domain entities
  □ No circular dependencies between modules

DATABASE ISOLATION:
  □ Each module has its own database schema
  □ No cross-schema JOINs in repository code
  □ Data from other modules is fetched via API calls, not direct queries
  □ Migration files are organized per module

EVENT BUS:
  □ Publishers do not know who the subscribers are
  □ Event handlers are thread-safe (no shared mutable state)
  □ Event types have descriptive names (past tense: order.placed)
  □ A failure in one handler does not cancel other handlers

BOUNDARY VALIDATION:
  □ A tool or test validates dependencies between modules
  □ CI/CD runs dependency validation on every push
  □ go test -race is run regularly

EVOLUTION READINESS:
  □ Each module's API interface is stable and documented
  □ Per-module database schemas have no cross-module foreign key constraints
  □ Event contracts are documented as candidates for Kafka/RabbitMQ

Summary #

  • The Modular Monolith is the sweet spot between monolith and microservices — one deployment with boundary discipline as if already distributed; all of a monolith’s operational advantages, with a design quality that enables evolution.
  • Three non-negotiable pillars — public interfaces (other modules may only depend on API interfaces), an internal event bus (async communication without direct coupling), and schema isolation (no cross-schema JOINs).
  • Each module exposes an API interface, not concrete structs — this ensures coupling always goes through stable contracts, never through implementation details that can change.
  • An in-process EventBus for loose coupling — the module publishing an event does not know who subscribes; this allows adding new subscribers without changing the publisher.
  • Schema isolation is a frequently overlooked boundary — cross-schema JOINs create hidden coupling at the database level that is hard to detect and hard to separate when a module needs extraction.
  • The Go internal/ package is a free boundary keeper — the Go compiler prevents imports from outside the parent directory; this is the most effective way to enforce boundaries without extra tooling.
  • Prepare for evolution from the start — design API interfaces, event contracts, and schemas as if the module will become a microservice; this makes future extraction a safe, gradual operation.
  • Best for teams of 5–30 developers — small enough for one deployment, large enough for clear boundaries; below that a Traditional Monolith suffices, above that consider extracting specific modules.
  • Not a forced stepping stone — a modular monolith can be a very effective end-game architecture; not every system needs to evolve to microservices.
  • Team discipline matters more than tooling — well-defined boundaries are useless if the team is not committed to respecting them; reviewing boundary violations in code review is as important as reviewing logic.

← Previous: Monolithic   Next: Microservice →

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