No Framework, No Pain: Writing Aether Slices
Source: Dev.to
Here’s an entire deployable service
@Slice
public interface OrderService {
Promise placeOrder(PlaceOrderRequest request);
static OrderService orderService(InventoryService inventory,
PricingEngine pricing) {
return request -> inventory.check(request.items())
.flatMap(pricing::calculate)
.map(OrderResult::placed);
}
}
That’s not a simplified example. That’s the actual thing.
- The annotation processor sees
@Slice, reads the factory‑method signature, and generates:- the wiring code,
- the proxy for remote calls,
- the deployment metadata.
You write one interface and you get a service that scales, fails over, and routes transparently across a distributed cluster.
No
@Autowired.
Noapplication.yml.
No@Configurationclass.
No@Beanmethod.
No component scan.
No service locator.
No dependency‑injection container at all.
The factory method is the dependency injection. Its parameters are the declared dependencies. The compiler verifies them; the annotation processor wires them. Nothing to configure, nothing to forget, nothing to debug at 2 AM.
Notice what the factory returns: a lambda. No implementation class. The interface has one method, so the factory returns a lambda that implements it directly—business logic as a function. For slices with multiple methods, a private record captures the dependencies and implements the interface—still no separate Impl class, no file to maintain, no indirection to trace.
Every slice follows two rules
-
Factory method declares dependencies – what the slice needs from the outside world appears in one place: the factory method signature.
static OrderService orderService(InventoryService inventory, PricingEngine pricing) { return request -> inventory.check(request.items()) .flatMap(pricing::calculate) .map(OrderResult::placed); }Read the factory, know the dependencies. No configuration file can contradict it. No runtime surprise can introduce a dependency the compiler hasn’t seen.
-
Promise return types – every method returns
Promise. This isn’t a stylistic choice; it’s what makes transparent distribution possible. Whether the call is in‑process or cross‑network, the caller sees the same type.
That’s it—two rules. Everything else follows from them.
How the annotation processor classifies factory parameters
| What the processor sees | What it does |
|---|---|
@PrimaryDb DatabaseConnector db | Resource – provisions from config |
InventoryService inventory | External slice – generates a network proxy |
OrderValidator validator | Local interface with factory – calls the factory directly |
You don’t configure this. You don’t annotate dependencies with @Inject or @Qualifier (except for infrastructure resources). You just list what you need, and the processor figures out how to provide it.
Infrastructure resources
A parameter annotated with @ResourceQualifier is infrastructure—a database connection, an HTTP client, a message queue. The processor provisions it from configuration:
@ResourceQualifier(type = DatabaseConnector.class, config = "database.primary")
public @interface PrimaryDb {}
@Slice
public interface OrderRepository {
Promise findOrder(FindOrderRequest request);
static OrderRepository orderRepository(@PrimaryDb DatabaseConnector db) {
return request -> db.query(request.orderId())
.map(OrderResult::fromRow);
}
}
Remote dependencies
A parameter that is a @Slice interface from another package is a remote dependency. The processor generates a proxy record that delegates to the runtime’s invocation fabric. Your code calls inventory.check(request) as a normal method call; the proxy handles serialization, routing, retry, and fail‑over.
Local dependencies
A parameter that is a plain interface with a static factory method is local. The processor calls the factory directly—no proxy, no network, no overhead.
All three categories together
static LoanService loanService(@PrimaryDb DatabaseConnector db,
CreditBureau creditBureau,
RiskCalculator riskCalculator) {
return request -> riskCalculator.assess(request)
.flatMap(risk -> creditBureau.check(request.applicant()))
.flatMap(credit -> persistDecision(db, request, credit));
}
- Database – from config.
- Credit bureau – via network proxy.
- Risk calculator – instantiated locally.
One line declares it all. The processor handles the rest.
Structured error handling
Frameworks train you to throw exceptions. Spring converts them to HTTP status codes, Jackson serializes error responses, and exception handlers map types to messages. It works until someone throws an unexpected exception, and the generic 500 response tells the caller nothing.
Slices use sealed Cause hierarchies:
public sealed interface OrderCause extends Cause {
OrderCause EMPTY_ORDER = new EmptyOrder("Order must have items");
OrderCause INSUFFICIENT_STOCK = new InsufficientStock("Insufficient stock");
static OrderCause insufficientStock(StockStatus stock) {
return new InsufficientStock("Insufficient stock: " + stock);
}
record EmptyOrder(String message) implements OrderCause {}
record InsufficientStock(String message) implements OrderCause {}
}
Every failure mode is a type. The compiler knows all of them. Pattern matching handles them exhaustively. No surprise NullPointerException masquerading as a 500 error.
Error handling as a business concern
When something fails you should return a failed promise using a fluent style instead of swallowing the exception:
return inventory.checkStock(stockRequest)
.flatMap(this::verifyAvailability);
private Promise verifyAvailability(StockStatus stock) {
return stock.sufficient()
? completeOrder(stock)
: OrderCause.insufficientStock(stock).promise();
}
- The runtime propagates the Cause across the network.
- The caller receives a typed failure, not a plain string message.
- The error‑handling contract becomes part of the API, not an after‑thought in a
@ControllerAdvice.
Testing without containers
- No test containers to spin up.
- No mock server to configure.
- No
@SpringBootTestthat loads half the universe. - No mocking frameworks – dependencies are interfaces, so you pass lambdas that return exactly what you need.
class OrderServiceTest {
@Test
void placeOrder_succeeds_whenInventoryAvailable() {
InventoryService inventory = request -> Promise.success(new StockResult("RES-123", true));
PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));
var service = OrderService.orderService(inventory, pricing);
service.placeOrder(request)
.await()
.onFailure(Assertions::fail)
.onSuccess(result -> assertEquals("ORD-456", result.orderId()));
}
@Test
void placeOrder_fails_whenInventoryUnavailable() {
InventoryService inventory = request -> OrderCause.INSUFFICIENT_STOCK.promise();
PricingEngine pricing = request -> Promise.success(new PriceResult("ORD-456", 99.99));
var service = OrderService.orderService(inventory, pricing);
service.placeOrder(request)
.await()
.onSuccess(Assertions::fail);
}
}
- Dependencies are interfaces – pass lambdas that return success or failure.
- The factory method wires them in.
- No reflection, no class‑path scanning, no context initialization.
- No Mockito, no
when(...).thenReturn(...), noverify(...).
Test startup is instant because there’s nothing to start: no container, no framework, no bean resolution – just objects calling objects.
Slice‑based architecture
A single Maven module can contain as many slices as make sense:
commerce/
src/main/java/org/example/
order/
OrderService.java # @Slice
payment/
PaymentService.java # @Slice
shipping/
ShippingService.java # @Slice
- Each
@Slicegenerates its own factory, API artifact, and deployment metadata. - The Maven plugin packages them separately.
- They deploy and scale independently while sharing domain types, build configuration, and repository.
Granular scaling
A slice can be as small as a single method. Because there’s no operational overhead (no container, no load balancer, no per‑service monitoring), you can scale slices individually:
- One slice serving 50 instances during peak load.
- Another slice idling at minimum capacity.
With Aether, this is the default behaviour.
What the annotation processor generates
Consider a simple slice with one external dependency:
@Slice
public interface OrderService {
Promise placeOrder(PlaceOrderRequest request);
static OrderService orderService(InventoryService inventory) {
return request -> inventory.check(request.items())
.map(OrderResult::fromAvailability);
}
}
The processor detects that InventoryService is an external @Slice and generates:
-
A proxy record that implements
InventoryServiceand delegates every method call to the runtime’sSliceInvokerFacade.
Your code callsinventory.check(request). The proxy serializes the request, routes it to a node hostingInventoryService, deserializes the response, and returns aPromise. -
A factory class that accepts an
Aspectand theSliceInvokerFacade, creates the proxy, and wires everything together. -
Deployment metadata in
META-INF/slice/– the slice name, its methods, and its dependencies.
The runtime reads this metadata to build the dependency graph and determine deployment order.
All of this is generated at compile time, verified, and invisible to your business logic.
Incrementally migrating existing Spring code
You don’t need to start from scratch. Any existing Java code can become a slice with a single line.
Legacy Spring service
@Service
public class OrderService {
@Autowired private InventoryRepository inventory;
@Autowired private PricingService pricing;
@Transactional
public OrderResult processOrder(OrderRequest request) {
var availability = inventory.checkAvailability(request.getItems());
if (!availability.isAvailable()) {
return OrderResult.outOfStock(availability.getMissingItems());
}
var quote = pricing.calculateQuote(request.getItems(), request.getCustomerId());
// ... payment, order creation, notification ...
return OrderResult.success(order);
}
}
Wrapped as a slice
@Slice
public interface OrderProcessor {
Promise processOrder(OrderRequest request);
static OrderProcessor orderProcessor() {
var legacyService = createLegacyService();
return request -> Promise.lift(() -> legacyService.processOrder(request));
}
}
Promise.lift()wraps the synchronous call, catches any exception, and returns a properPromisewith a typed failure instead of a raw stack trace.
The wrapped slice works, but it’s a black box. The peeling pattern opens it up incrementally—one layer at a time—while keeping working code at every step.
Peeling the outer structure
Replace the opaque lift() with a Sequencer where each step is still wrapped:
return Promise.lift(() -> legacyCheckInventory(request))
.flatMap(inv -> Promise.lift(() -> legacyCalculatePricing(inv)))
.flatMap(quote -> Promise.lift(() -> legacyProcessPayment(quote, request)));
Now each stage is explicit, testable, and composable, while the overall flow remains a single, type‑safe promise chain.
Migration walkthrough
ote)))
.flatMap(payment -> Promise.lift(() -> legacyCreateOrder(payment)));
Now the pipeline is visible. You can see the steps, test them individually, and reason about the flow.
Peel one step deeper
Take the hottest lift() and expand it:
private Promise checkInventory(OrderRequest request) {
return Promise.all(
Promise.lift(() -> legacyCheckWarehouse(request)),
Promise.lift(() -> legacyCheckSupplier(request))
)
.map(this::combineAvailability);
}
- The outer call is now clean JBCT.
- The inner calls are still wrapped.
- Tests pass at every step.
Stop anywhere – mixed JBCT and legacy code works fine. The remaining lift() calls mark exactly where legacy code lives. When they’re all gone, you have a clean slice. There’s no deadline; each peeling step delivers value on its own.
The full migration walkthrough covers the complete path – from initial wrapping through fault tolerance to clean JBCT code.
Why slice development is different
Traditional micro‑service development is a negotiation with frameworks:
- You learn their abstractions, lifecycle hooks, configuration DSLs, annotation model, error‑handling conventions, and testing utilities.
- The framework becomes the center of gravity; your business logic orbits around it.
Slices invert this.
- Business logic is the center.
- The interface defines the contract.
- The factory method declares dependencies.
- The implementation is a lambda.
Everything else – serialization, routing, scaling, failover, configuration – is the runtime’s problem.
“You don’t learn a framework. You write Java interfaces and implement them.”
Two rules. The rest is just your domain.
No framework. No pain.
Resources
- Pragmatica Aether – distributed Java runtime
- GitHub Repository – [source code]
- Slice Development Guide – full reference