Multi-slice example for 10–30 entities

When your service grows beyond a handful of entities, folder salad looms. After all,

Once you pass five entities, a flat β€œone-entity demo” turns into a Where’s-Waldo. The fix is to group by feature/bounded context (vertical slices), and keep the same edges ↔ core rhythm inside each slice. For an inventory system, four slices keep you sane:

  • catalog/ β€” products, categories, brands
  • inventory/ β€” warehouses, stock levels, movements
  • sales/ β€” customers, orders, order items
  • procurement/ β€” suppliers, purchase orders

Here’s a paste-ready skeleton you can drop into src/. It scales without devolving into folder salad.

inventory-service/
β”œβ”€ build.gradle / pom.xml
β”œβ”€ README.md
β”œβ”€ .editorconfig
β”œβ”€ .gitignore
└─ src
   β”œβ”€ main
   β”‚  β”œβ”€ java/com/edge/inventory
   β”‚  β”‚  β”œβ”€ web/                                  # HTTP edge: controllers, DTOs, ProblemDetail
   β”‚  β”‚  β”‚  β”œβ”€ catalog/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ ProductController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CategoryController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ dto/
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateProductRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ UpdateProductRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ ProductResponse.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCategoryRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  └─ CategoryResponse.java
   β”‚  β”‚  β”‚  β”‚  └─ mapper/
   β”‚  β”‚  β”‚  β”‚     β”œβ”€ ProductWebMapper.java
   β”‚  β”‚  β”‚  β”‚     └─ CategoryWebMapper.java
   β”‚  β”‚  β”‚  β”œβ”€ inventory/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ WarehouseController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ StockItemController.java       # /warehouses/{id}/stock
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ dto/
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateWarehouseRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ WarehouseResponse.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ AdjustStockRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  └─ StockItemResponse.java
   β”‚  β”‚  β”‚  β”‚  └─ mapper/
   β”‚  β”‚  β”‚  β”‚     β”œβ”€ WarehouseWebMapper.java
   β”‚  β”‚  β”‚  β”‚     └─ StockWebMapper.java
   β”‚  β”‚  β”‚  β”œβ”€ sales/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ OrderController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CustomerController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ dto/
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateOrderRequest.java     # contains line items
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ OrderResponse.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCustomerRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  └─ CustomerResponse.java
   β”‚  β”‚  β”‚  β”‚  └─ mapper/
   β”‚  β”‚  β”‚  β”‚     β”œβ”€ OrderWebMapper.java
   β”‚  β”‚  β”‚  β”‚     └─ CustomerWebMapper.java
   β”‚  β”‚  β”‚  β”œβ”€ procurement/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ SupplierController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ PurchaseOrderController.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ dto/
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateSupplierRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ SupplierResponse.java
   β”‚  β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreatePurchaseOrderRequest.java
   β”‚  β”‚  β”‚  β”‚  β”‚  └─ PurchaseOrderResponse.java
   β”‚  β”‚  β”‚  β”‚  └─ mapper/
   β”‚  β”‚  β”‚  β”‚     β”œβ”€ SupplierWebMapper.java
   β”‚  β”‚  β”‚  β”‚     └─ PurchaseOrderWebMapper.java
   β”‚  β”‚  β”‚  β”œβ”€ error/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ GlobalExceptionHandler.java    # @RestControllerAdvice β†’ ProblemDetail
   β”‚  β”‚  β”‚  β”‚  └─ ProblemTypes.java              # stable type URIs / optional codes
   β”‚  β”‚  β”‚  └─ config/
   β”‚  β”‚  β”‚     └─ WebConfig.java
   β”‚  β”‚  β”œβ”€ app/                                  # Use-cases: orchestrate domain + repos, @Transactional
   β”‚  β”‚  β”‚  β”œβ”€ catalog/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateProductUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ UpdateProductUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ ListProductsUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCategoryUseCase.java
   β”‚  β”‚  β”‚  β”‚  └─ CatalogService.java            # implements multiple use-case interfaces
   β”‚  β”‚  β”‚  β”œβ”€ inventory/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateWarehouseUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ AdjustStockUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ TransferStockUseCase.java
   β”‚  β”‚  β”‚  β”‚  └─ InventoryService.java
   β”‚  β”‚  β”‚  β”œβ”€ sales/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateOrderUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ GetOrderUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateCustomerUseCase.java
   β”‚  β”‚  β”‚  β”‚  └─ SalesService.java
   β”‚  β”‚  β”‚  β”œβ”€ procurement/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreateSupplierUseCase.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ CreatePurchaseOrderUseCase.java
   β”‚  β”‚  β”‚  β”‚  └─ ProcurementService.java
   β”‚  β”‚  β”‚  └─ exception/
   β”‚  β”‚  β”‚     β”œβ”€ ProductNotFoundException.java
   β”‚  β”‚  β”‚     β”œβ”€ CategoryNotFoundException.java
   β”‚  β”‚  β”‚     β”œβ”€ WarehouseNotFoundException.java
   β”‚  β”‚  β”‚     β”œβ”€ InsufficientStockException.java
   β”‚  β”‚  β”‚     β”œβ”€ OrderNotFoundException.java
   β”‚  β”‚  β”‚     └─ SupplierNotFoundException.java
   β”‚  β”‚  β”œβ”€ domain/                               # Entities + repository ports (interfaces)
   β”‚  β”‚  β”‚  β”œβ”€ catalog/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Product.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Category.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Brand.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ ProductRepository.java
   β”‚  β”‚  β”‚  β”‚  └─ CategoryRepository.java
   β”‚  β”‚  β”‚  β”œβ”€ inventory/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Warehouse.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ StockItem.java                 # product_id + warehouse_id + qty
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ StockMovement.java             # audit of adjustments/transfers
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ WarehouseRepository.java
   β”‚  β”‚  β”‚  β”‚  └─ StockRepository.java
   β”‚  β”‚  β”‚  β”œβ”€ sales/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Customer.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Order.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ OrderItem.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ OrderRepository.java
   β”‚  β”‚  β”‚  β”‚  └─ CustomerRepository.java
   β”‚  β”‚  β”‚  β”œβ”€ procurement/
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ Supplier.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ PurchaseOrder.java
   β”‚  β”‚  β”‚  β”‚  β”œβ”€ PurchaseOrderItem.java
   β”‚  β”‚  β”‚  β”‚  └─ PurchaseOrderRepository.java
   β”‚  β”‚  β”‚  └─ common/
   β”‚  β”‚  β”‚     β”œβ”€ BaseEntity.java
   β”‚  β”‚  β”‚     β”œβ”€ Money.java                     # value object (amount + currency)
   β”‚  β”‚  β”‚     └─ Quantity.java                  # value object for stock quantities
   β”‚  β”‚  └─ infra/                               # Adapters/config
   β”‚  β”‚     β”œβ”€ persistence/
   β”‚  β”‚     β”‚  β”œβ”€ JpaConfig.java
   β”‚  β”‚     β”‚  └─ SpecBuilders.java              # optional: Spring Data Specifications
   β”‚  β”‚     β”œβ”€ mapping/
   β”‚  β”‚     β”‚  └─ MapStructConfig.java
   β”‚  β”‚     └─ security/
   β”‚  β”‚        └─ SecurityConfig.java            # optional later
   β”‚  └─ resources
   β”‚     β”œβ”€ application.yml
   β”‚     β”œβ”€ application-local.yml
   β”‚     β”œβ”€ db/migration/
   β”‚     β”‚  β”œβ”€ V1__init_catalog.sql
   β”‚     β”‚  β”œβ”€ V2__init_inventory.sql
   β”‚     β”‚  β”œβ”€ V3__init_sales.sql
   β”‚     β”‚  └─ V4__init_procurement.sql
   β”‚     └─ logback-spring.xml
   └─ test
      β”œβ”€ java/com/edge/inventory
      β”‚  β”œβ”€ web/catalog/
      β”‚  β”‚  β”œβ”€ ProductControllerIT.java
      β”‚  β”‚  └─ CategoryControllerIT.java
      β”‚  β”œβ”€ web/inventory/
      β”‚  β”‚  └─ WarehouseControllerIT.java
      β”‚  β”œβ”€ app/catalog/
      β”‚  β”‚  └─ CatalogServiceTest.java
      β”‚  β”œβ”€ app/inventory/
      β”‚  β”‚  └─ InventoryServiceTest.java
      β”‚  β”œβ”€ domain/catalog/
      β”‚  β”‚  └─ ProductRepositoryIT.java
      β”‚  └─ domain/inventory/
      β”‚     └─ StockRepositoryIT.java
      └─ resources/
         └─ application-test.yml

Why this stays navigable with 10–30 entities

  • Vertical slices: You always know where to look. All β€œproduct” stuff is under catalog/product* across layers.
  • Repeatable pattern: Each slice has web/app/domain subfolders with parallel names. Muscle memory beats search.
  • Shared core: domain/common holds value objects and base types used cross-slice, not a dumping ground.

Relationships that won’t tangle your brain

  • Product ↔ Category (many-to-one).
  • StockItem ties Product + Warehouse with quantity.
  • Order has OrderItems (each references Product and a snapshot price).
  • PurchaseOrder mirrors Order but with Supplier and incoming quantities.
  • Keep aggregates small: Order owns OrderItems; Product doesn’t own StockItem rows (inventory slice does).

MapStruct placement rule (unchanged)

  • Web mappers live at the edge (web/.../mapper), converting Request ↔ Command and Entity ↔ Response.
  • If you add persistence projections/specs, put those mappers in infra/mapping/.
// infra/mapping/MapStructConfig.java
@Configuration
@MapperConfig(
  componentModel = "spring",
  unmappedTargetPolicy = ReportingPolicy.ERROR,
  nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public class MapStructConfig {}

Then annotate each mapper: @Mapper(config = MapStructConfig.class).

Use-case seams (how services don’t balloon)

  • One service per slice is fine early: CatalogService, InventoryService, etc., implement multiple use-case interfaces.
  • When a service grows obese, split by workflow: e.g., OrderingService vs OrderQueryService.
  • Keep commands/inputs narrow: CreateOrderRequest includes line items; AdjustStockRequest is just product+warehouse+delta.

ProblemDetail catalog (so errors stay boring)

  • Keep GlobalExceptionHandler in web/error/.
  • Put stable type URIs in ProblemTypes like:

  • /problems/catalog/product-not-found

  • /problems/inventory/insufficient-stock
  • Base URI (problems.base) in application.yml, read as @ConfigurationProperties so you don’t hardcode strings.

Flyway starter pack (one file per slice)

V1__init_catalog.sql

CREATE TABLE category(
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

CREATE TABLE product(
  id BIGSERIAL PRIMARY KEY,
  sku TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  category_id BIGINT NOT NULL REFERENCES category(id)
);

V2__init_inventory.sql

CREATE TABLE warehouse(
  id BIGSERIAL PRIMARY KEY,
  code TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL
);

CREATE TABLE stock_item(
  product_id BIGINT NOT NULL REFERENCES product(id),
  warehouse_id BIGINT NOT NULL REFERENCES warehouse(id),
  quantity NUMERIC(19,2) NOT NULL DEFAULT 0,
  PRIMARY KEY(product_id, warehouse_id)
);

CREATE TABLE stock_movement(
  id BIGSERIAL PRIMARY KEY,
  product_id BIGINT NOT NULL REFERENCES product(id),
  warehouse_id BIGINT NOT NULL REFERENCES warehouse(id),
  delta NUMERIC(19,2) NOT NULL,
  reason TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

V3__init_sales.sql

CREATE TABLE customer(
  id BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL
);

CREATE TABLE "order"(
  id BIGSERIAL PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customer(id),
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_item(
  order_id BIGINT NOT NULL REFERENCES "order"(id),
  line_no INT NOT NULL,
  product_id BIGINT NOT NULL REFERENCES product(id),
  qty NUMERIC(19,2) NOT NULL,
  unit_price NUMERIC(19,2) NOT NULL,
  PRIMARY KEY(order_id, line_no)
);

V4__init_procurement.sql

CREATE TABLE supplier(
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE,
  email TEXT
);

CREATE TABLE purchase_order(
  id BIGSERIAL PRIMARY KEY,
  supplier_id BIGINT NOT NULL REFERENCES supplier(id),
  status TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE purchase_order_item(
  purchase_order_id BIGINT NOT NULL REFERENCES purchase_order(id),
  line_no INT NOT NULL,
  product_id BIGINT NOT NULL REFERENCES product(id),
  qty NUMERIC(19,2) NOT NULL,
  unit_cost NUMERIC(19,2) NOT NULL,
  PRIMARY KEY(purchase_order_id, line_no)
);

Testing layout that mirrors slices

  • Controller IT per slice (web/catalog/*IT.java) asserting Location, JSON shape, and ProblemDetails.
  • Service tests per slice for business rules (stock never negative, order totals).
  • Repo slice tests (@DataJpaTest) for constraints (UNIQUE, FKs) and query specs.
  • Add package-info.java in each slice (web/catalog, app/inventory, …) with a one-paragraph doc of what lives there.
  • Put a tiny README.md in each top slice folder explaining flows and key use-cases.
  • Keep DTO names scenario-first (AdjustStockRequest, TransferStockRequest) rather than generic (UpdateWarehouse).