DEV Community

Ahmad Karawash
Ahmad Karawash

Posted on

Amoxi: Dependency injection that fails the build, not production

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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) };
  }
}
Enter fullscreen mode Exit fullscreen mode

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', '!'))
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
$env:GREETING_PUNCTUATION="."
Enter fullscreen mode Exit fullscreen mode
npm start
Enter fullscreen mode Exit fullscreen mode

(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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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() };
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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)