We optimize for three goals:
- Clean Code
- Testability
- Fail Fast
We use a pragmatic, adapted form of hexagonal architecture: take the strengths (clear seams for change and testing) and avoid dogma (no needless layers, no over-abstraction).
- Domain: business logic and deterministic flow. Domain may use framework annotations if that helps productivity, but it must not depend on adapters.
- Adapter: glue code for IO and external systems. Adapter should be very small and should not contain business rules.
- Port: an interface in domain that describes a capability domain needs from the outside (e.g., payment gateway, email sender).
Rules of thumb:
- Prefer direct code over unnecessary layers/abstractions.
- Minimize layering: only introduce layers when they add clear value (not for “architecture aesthetics”).
- Prefer “script-style” straightforward flow for small features: readable, linear code beats over-engineered indirection.
- Prefer high-quality, well-adopted libraries over verbose low-level code when it reduces boilerplate and improves readability (e.g., use a solid HTTP client library instead of manual
HttpURLConnectionplumbing). - Prefer avoiding checked exceptions in application code. If a library API exposes checked exceptions and you have no meaningful recovery/fallback, use
@SneakyThrowsto keep code linear and readable instead of repetitivetry/catch+ rethrow boilerplate. - Keep tiny, private data structures close to where they are used (e.g.,
private static record/private static class) instead of creating global types prematurely. - Organize code by feature (vertical slice) rather than by technical type buckets (
service/impl/dto/controller/...). - Avoid speculative branches/defaults/fallbacks (“just in case”).
- Delete dead code/config after changes; do not keep unused remnants.
Good example (avoid checked-exception boilerplate):
package com.example.app.domain.checkout;
import lombok.SneakyThrows;
import java.nio.file.Files;
import java.nio.file.Path;
final class ReceiptTemplateLoader {
@SneakyThrows
static String load(Path path) {
return Files.readString(path);
}
}Bad example (manual try/catch + rethrow noise):
try {
return Files.readString(path);
} catch (IOException e) {
throw new RuntimeException(e);
}Why: if there is no meaningful fallback/recovery, repetitive try/catch blocks add noise without improving behavior or readability.
Good example (direct, minimal domain flow):
package com.example.app.domain.checkout;
import java.util.List;
public class CheckoutService {
public Receipt checkout(String userId, List<Item> items) {
Pricing pricing = price(items);
OrderId orderId = createOrder(userId, items, pricing.total());
return new Receipt(orderId, pricing.total());
}
private Pricing price(List<Item> items) {
// ...pure pricing logic...
return new Pricing(Money.ofCents(12_345));
}
private OrderId createOrder(String userId, List<Item> items, Money total) {
// ...deterministic order creation...
return OrderId.newId();
}
private static record Pricing(Money total) {}
public static record Receipt(OrderId orderId, Money total) {}
}Why: the core flow is linear and readable in domain, and the small helper type (Pricing) stays private to avoid type sprawl.
Good example (feature-first layout with top-level domain):
src/main/java/com/example/app/
domain/
checkout/
CheckoutService.java
PaymentGatewayPort.java
catalog/
CatalogService.java
adapter/
checkout/
web/CheckoutController.java
payment/StripePaymentGatewayAdapter.java
catalog/
web/CatalogController.java
Bad example (type buckets; hard to follow a feature end-to-end):
src/main/java/com/example/app/
controller/
service/
service/impl/
dto/
adapter/
Bad example (layering without value):
return presenter.present(useCase.handle(facade.checkout(request)));Why: if these layers only forward parameters/results, they add noise, increase change surface, and accumulate dead code.
Rules of thumb:
- We use a pragmatic hexagonal architecture to improve testability; take the strengths, avoid dogma.
- Strict rule:
domainmust never depend onadapter(e.g., domain code must not import anything under an..adapter..package). - Maximize
domain, minimizeadapter: deterministic logic stays in domain; adapter is IO/glue only. - Only external, mutable systems belong in adapters (e.g., third-party APIs, object storage, message brokers). Keep deterministic logic in domain whenever it reduces mocks and improves clarity.
- Outbound adapters should keep semantics close to the external API and avoid business decisions/defaulting/branching.
- Tests prefer HTTP E2E; domain is never mocked; only external mutable dependencies may be stubbed/mocked.
- For local-deployable dependencies (DB/Redis/etc), use real instances (isolated per suite) rather than introducing adapters/mocks “for testability”.
- Reproducibility: freeze non-deterministic boundaries; keep deterministic pipelines real to minimize mocks.
Good example (domain depends on ports; adapter implements them with minimal glue):
package com.example.app.domain.checkout;
import java.util.List;
public interface PaymentGatewayPort {
PaymentAuthorization authorize(Money amount, PaymentMethod method);
}
public class CheckoutService {
private final PaymentGatewayPort paymentGatewayPort;
public CheckoutService(PaymentGatewayPort paymentGatewayPort) {
this.paymentGatewayPort = paymentGatewayPort;
}
public Receipt checkout(String userId, List<Item> items, PaymentMethod method) {
Money total = Money.ofCents(12_345);
paymentGatewayPort.authorize(total, method);
return new Receipt(OrderId.newId(), total);
}
public static record Receipt(OrderId orderId, Money total) {}
}package com.example.app.adapter.checkout.payment;
import com.example.app.domain.checkout.PaymentGatewayPort;
public class StripePaymentGatewayAdapter implements PaymentGatewayPort {
@Override
public PaymentAuthorization authorize(Money amount, PaymentMethod method) {
// Call Stripe SDK/API and map the response; no business rules here.
return PaymentAuthorization.approved();
}
}Why: the core rule is preserved—domain expresses what it needs via a port; adapter is thin glue around an external mutable system.
Good example (E2E HTTP test + stub only external ports; do not mock domain):
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class CheckoutControllerTest {
@TestConfiguration
static class Stubs {
@Bean PaymentGatewayPort paymentGatewayPort() { /* return fixed response */ }
}
}Why: the test exercises real HTTP wiring + real domain logic + real DB (if applicable); only the external mutable boundary is stubbed.
Bad example (domain imports adapter / embeds external client directly):
package com.example.app.domain.checkout;
import com.example.app.adapter.checkout.payment.StripePaymentGatewayAdapter; // ❌ adapter import inside domainWhy: this couples domain to IO details, makes tests harder, and breaks the strict boundary.
Rules of thumb:
- No defensive programming by default: do not add “just in case” guards; only handle exceptions when fallback behavior is explicitly required.
- In tests: no pre-checks / no “skip if missing”; let it fail loudly.
- Fail fast on misconfiguration: validate required inputs at startup (prefer
InitializingBean#afterPropertiesSet()). - No runtime provisioning (environment is a contract): do not modify
PATH, do not auto-install/download toolchains, do not create system-level dependencies.
Good example (startup validation):
@Override
public void afterPropertiesSet() {
if (paymentProperties.getApiKey() == null || paymentProperties.getApiKey().isBlank()) {
throw new IllegalStateException("Missing payment.api-key");
}
}Why: configuration problems surface immediately at startup with a clear error.
Bad example (swallowing errors):
try {
// do something important
} catch (Exception ignored) {
return "";
}Why: this hides the root cause and tends to fail later in harder-to-debug ways.