How Amoxi wires your services together at build time, how configuration reaches them, and the mistakes it catches before you deploy.
In article 1, NoteController asked for a NoteService in its constructor and simply got one. This article shows how that works, and why it matters more than it looks.
Most TypeScript frameworks with dependency injection, NestJS for example, find your services at startup through reflection. A missing or ambiguous dependency then shows up as a crash when the server starts, or worse, when a rarely used route is first called. Amoxi does the work in amoxi build instead, with the TypeScript compiler, the way Micronaut and Dagger do for Java. Wiring mistakes become build errors with a file and a line, and the running app contains no reflection at all.
We will build a small greeting API and break it on purpose a few times.
Services are classes with a decorator
Create src/greetings/greeter.ts with an interface:
export interface Greeter {
greet(name: string): string;
}
And one implementation, src/greetings/friendly-greeter.ts:
import { Singleton } from 'amoxi';
import type { Greeter } from './greeter';
@Singleton()
export class FriendlyGreeter implements Greeter {
greet(name: string): string {
return `Hi ${name}`;
}
}
Amoxi calls such a class a bean. @Singleton() gives the app one shared instance; @Prototype() would create a new one for each place that asks.
Now a service that depends on any Greeter, plus a setting from configuration, in src/greetings/greeting-service.ts:
import { Singleton, Value } from 'amoxi';
import type { Greeter } from './greeter';
@Singleton()
export class GreetingService {
constructor(
private readonly greeter: Greeter,
@Value('${greeting.punctuation:!}') private readonly punctuation: string,
) {}
message(name: string): string {
return `${this.greeter.greet(name)}${this.punctuation}`;
}
}
The constructor asks for the interface, not a class. Amoxi finds the bean that implements it. @Value('${greeting.punctuation:!}') reads the setting greeting.punctuation, with ! when it is not set.
Finally, a route in src/greetings/greeting-controller.ts:
import { Controller, Get, PathParam } from 'amoxi';
import { GreetingService } from './greeting-service';
@Controller('/api/greet')
export class GreetingController {
constructor(private readonly greetings: GreetingService) {}
@Get('/:name')
greet(@PathParam() name: string) {
return { message: this.greetings.message(name) };
}
}
Run npm run dev and open http://localhost:8080/api/greet/Sam. You get {"message":"Hi Sam!"}.
What the build wrote
Open src/generated/amoxi.generated.ts. Among other things, it contains one line per bean, and each is an ordinary constructor call with the dependencies already resolved. Simplified:
create: (r) => new GreetingService(r.get(4), r.config.string('greeting.punctuation', '!'))
Nothing is looked up by name or type at runtime. That is why an Amoxi app is ready in tens of milliseconds, and why there is nothing left to go wrong when it starts.
Break it: two candidates
Add a second implementation, src/greetings/formal-greeter.ts:
import { Singleton } from 'amoxi';
import type { Greeter } from './greeter';
@Singleton()
export class FormalGreeter implements Greeter {
greet(name: string): string {
return `Good morning, ${name}`;
}
}
The dev server shows an error overlay, and npm run build stops with:
error AMOXI: src/greetings/greeting-service.ts:7 - Multiple beans of type 'Greeter' match parameter 'greeter'
of 'GreetingService': FormalGreeter, FriendlyGreeter. Add @Named('...') to the parameter, mark one bean
@Primary, or inject 'Greeter[]' to receive all of them.
The message lists every fix:
-
@Primary()on one class makes it the default. -
@Named('formal')on a class, plus@Named('formal')on the constructor parameter, picks one explicitly. - A parameter typed
readonly Greeter[]receives all of them, for example to run every notifier in turn.
Choose by configuration with @Requires
Often the right choice depends on the environment: a real mail sender in production, a logging one on your laptop. @Requires turns a bean on only when a condition holds. Change the formal greeter:
import { Requires, Singleton } from 'amoxi';
import type { Greeter } from './greeter';
@Singleton()
@Requires({ property: 'greeting.style', value: 'formal' })
export class FormalGreeter implements Greeter {
greet(name: string): string {
return `Good morning, ${name}`;
}
}
The build passes again. FriendlyGreeter has no condition, so it is the default, and FormalGreeter replaces it when greeting.style is formal. Conditions can also test profile, notProfile or a missing property.
Where configuration comes from
Settings are read, in order, from application.json, then application-<profile>.json, then .env files, then environment variables, then --key=value arguments. The profile is development under npm run dev and production under npm start.
Environment variable names are the key in capitals, with dots as underscores and dashes left out. So greeting.style is GREETING_STYLE. In PowerShell:
$env:GREETING_STYLE="formal"
$env:GREETING_PUNCTUATION="."
npm start
(In bash, use export GREETING_STYLE=formal instead.) Now /api/greet/Sam answers {"message":"Good morning, Sam."}. Same build, different behaviour, decided once at startup.
If a configuration leaves a dependency with no candidate at all, startup stops with a clear message instead of failing later on a request. Delete FriendlyGreeter and start without GREETING_STYLE, and you get:
NoSuchBeanError: 'GreetingService' needs one of [FormalGreeter], but @Requires disabled all of them
for this configuration (profile development)
Two more mistakes the build catches
Ask for something that does not exist, such as a Clock interface with no implementation:
error AMOXI: src/greetings/greeting-service.ts:9 - No bean of type 'Clock' exists for parameter 'clock'
of 'GreetingService'. Annotate an implementation with @Singleton, or provide it from a @Factory @Bean method.
Or make two beans need each other:
error AMOXI: src/greetings/cycle.ts:2 - Circular dependency: A -> B -> A. Break the cycle by extracting
shared logic into a third bean.
The same goes for route parameters that are not in the path, misnamed components and invalid validation rules: they all stop the build, never a running server.
Classes you cannot decorate
A class from a library cannot carry @Singleton(). Provide it from a factory method instead:
import { Bean, Factory } from 'amoxi';
import type { Clock } from './clock';
@Factory()
export class InfrastructureFactory {
@Bean()
clock(): Clock {
return { now: () => new Date() };
}
}
Anything that asks for a Clock now gets this one.
Swapping beans in tests
Tests start the real app and replace what they need. A fixed clock, for example:
const app = await AmoxiApplication.run(applicationModule, {
config: Config.of({ server: { port: 0 }, greeting: { style: 'formal' } }),
overrides: [['Clock', { now: () => new Date('2026-01-15T09:00:00Z') }]],
silent: true,
});
Config.of sets configuration directly, without files, and overrides replaces a bean by its type name.
Next
Article 3 moves to the browser side: pages rendered on the server, small interactive islands, links that do not reload the page, and sections that stream in when their data is ready.
Amoxi is open source under the Apache License 2.0. Code: github.com/karawash-projects/amoxi. Documentation: karawash-projects.github.io/amoxi.
Top comments (0)