Design Patterns, Explained Simply · Patterns Inside Frameworks
The Service Container and Service Providers
Let one object build and hand out your application's services (the container), and give each feature one place to register its services when the app starts (service providers).
The problem
The Dependency Injection lesson passed every dependency in by hand: Notifier(SmtpMailer(config)). That is easy with five classes. With two hundred, the code that creates and connects them all becomes a huge file that every feature edits.
The wiring also has rules of its own:
- Local development should log emails instead of sending them, so the choice of mailer depends on the environment.
- Some objects must be shared: one database connection pool, one cache client. Others must be new each time: a request-specific report builder.
- A payments package needs to add its own services and routes without you editing its code.
Real-world analogy
A staffing agency. You don't hire an electrician yourself; you ask the agency for "an electrician" and it decides who to send. Some roles are always the same person (the site manager, a shared service), and some get a fresh worker for every job.
Each trade has an onboarding desk that, when the agency opens in the morning, tells the agency which workers it has and how to book them. Only after every desk has finished does the day's work start, so any trade can rely on any other being on the books.
The agency is the service container; the onboarding desks are service providers.
Without the pattern
// public/index.php grows with every feature, and every environment check is copied into it
$config = require 'config.php';
$mailer = $config['env'] === 'local' ? new LogMailer() : new SmtpMailer($config['smtp']);
$db = new Connection($config['db']);
$orders = new OrderRepository($db);
$notifier = new Notifier($mailer);
$checkout = new CheckoutService($orders, $notifier, new StripeGateway($config['stripe']));
// ... 200 more lines
The pattern
Service container (also called a dependency injection container or IoC container):
- Keeps bindings: a name or interface mapped to a factory that knows how to build it.
bind(name, factory): build a new object every time it is asked for.singleton(name, factory): build it once, then share that instance. This is a Singleton scoped to the application, not a global static.make(name): build the object and, through the factories, everything it depends on.- Most containers also auto-wire: they read a class's constructor type hints and build those dependencies without any binding.
Service providers organise the bindings by feature. Each provider has two steps, and the framework runs them in two passes:
- register: only bind things into the container. Don't use other services yet; they may not be registered.
- boot: runs after every provider has registered, so it can safely use any service: register routes, event listeners, view helpers.
Starting the application ("bootstrapping") is then: create the container, call register on every provider, then call boot on every provider.
The code
A container with bind, singleton and make, two providers and the two-pass bootstrap. The notification provider is listed first and still works, because services are only built when first asked for.
class Container:
def __init__(self):
self.bindings = {}
self.instances = {}
def bind(self, name, factory):
self.bindings[name] = (factory, False)
def singleton(self, name, factory):
self.bindings[name] = (factory, True)
def make(self, name):
if name in self.instances:
return self.instances[name]
factory, shared = self.bindings[name]
obj = factory(self)
if shared:
self.instances[name] = obj
return obj
class LogMailer:
def send(self, to, text):
return f"[log] to {to}: {text}"
class SmtpMailer:
def send(self, to, text):
return f"[smtp] to {to}: {text}"
class Notifier:
def __init__(self, mailer):
self.mailer = mailer
def welcome(self, email):
return self.mailer.send(email, "Welcome!")
class MailServiceProvider:
def register(self, app):
# Only bind here. The factory reads the config when the mailer is first needed.
app.singleton("mailer", lambda c: LogMailer() if c.make("config")["env"] == "local" else SmtpMailer())
def boot(self, app):
pass
class NotificationServiceProvider:
def register(self, app):
app.bind("notifier", lambda c: Notifier(c.make("mailer")))
def boot(self, app):
# Every provider has registered by now, so using other services is safe.
print("boot: notifier uses", type(app.make("notifier").mailer).__name__)
def bootstrap(env):
app = Container()
app.singleton("config", lambda c: {"env": env})
providers = [NotificationServiceProvider(), MailServiceProvider()]
for provider in providers:
provider.register(app)
for provider in providers:
provider.boot(app)
return app
app = bootstrap("local")
print(app.make("notifier").welcome("[email protected]"))
print("same mailer:", "yes" if app.make("mailer") is app.make("mailer") else "no")
print("same notifier:", "yes" if app.make("notifier") is app.make("notifier") else "no")
print(bootstrap("production").make("notifier").welcome("[email protected]"))
Output
boot: notifier uses LogMailer [log] to [email protected]: Welcome! same mailer: yes same notifier: no boot: notifier uses SmtpMailer [smtp] to [email protected]: Welcome!
type Factory = (c: Container) => any;
class Container {
private bindings = new Map<string, { factory: Factory; shared: boolean }>();
private instances = new Map<string, any>();
bind(name: string, factory: Factory) {
this.bindings.set(name, { factory, shared: false });
}
singleton(name: string, factory: Factory) {
this.bindings.set(name, { factory, shared: true });
}
make<T = any>(name: string): T {
if (this.instances.has(name)) return this.instances.get(name);
const { factory, shared } = this.bindings.get(name)!;
const obj = factory(this);
if (shared) this.instances.set(name, obj);
return obj;
}
}
interface Mailer {
send(to: string, text: string): string;
}
class LogMailer implements Mailer {
send(to: string, text: string) {
return `[log] to ${to}: ${text}`;
}
}
class SmtpMailer implements Mailer {
send(to: string, text: string) {
return `[smtp] to ${to}: ${text}`;
}
}
class Notifier {
constructor(public mailer: Mailer) {}
welcome(email: string) {
return this.mailer.send(email, "Welcome!");
}
}
interface Provider {
register(app: Container): void;
boot(app: Container): void;
}
class MailServiceProvider implements Provider {
register(app: Container) {
// Only bind here. The factory reads the config when the mailer is first needed.
app.singleton("mailer", (c) => (c.make("config").env === "local" ? new LogMailer() : new SmtpMailer()));
}
boot() {}
}
class NotificationServiceProvider implements Provider {
register(app: Container) {
app.bind("notifier", (c) => new Notifier(c.make<Mailer>("mailer")));
}
boot(app: Container) {
// Every provider has registered by now, so using other services is safe.
console.log("boot: notifier uses", app.make<Notifier>("notifier").mailer.constructor.name);
}
}
function bootstrap(env: string): Container {
const app = new Container();
app.singleton("config", () => ({ env }));
const providers: Provider[] = [new NotificationServiceProvider(), new MailServiceProvider()];
for (const provider of providers) provider.register(app);
for (const provider of providers) provider.boot(app);
return app;
}
const app = bootstrap("local");
console.log(app.make<Notifier>("notifier").welcome("[email protected]"));
console.log("same mailer:", app.make("mailer") === app.make("mailer") ? "yes" : "no");
console.log("same notifier:", app.make("notifier") === app.make("notifier") ? "yes" : "no");
console.log(bootstrap("production").make<Notifier>("notifier").welcome("[email protected]"));
Output
boot: notifier uses LogMailer [log] to [email protected]: Welcome! same mailer: yes same notifier: no boot: notifier uses SmtpMailer [smtp] to [email protected]: Welcome!
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
public class Main {
static class Container {
private final Map<String, Function<Container, Object>> factories = new HashMap<>();
private final Map<String, Boolean> shared = new HashMap<>();
private final Map<String, Object> instances = new HashMap<>();
void bind(String name, Function<Container, Object> factory) { factories.put(name, factory); shared.put(name, false); }
void singleton(String name, Function<Container, Object> factory) { factories.put(name, factory); shared.put(name, true); }
@SuppressWarnings("unchecked")
<T> T make(String name) {
if (instances.containsKey(name)) return (T) instances.get(name);
Object obj = factories.get(name).apply(this);
if (shared.get(name)) instances.put(name, obj);
return (T) obj;
}
}
interface Mailer { String send(String to, String text); }
static class LogMailer implements Mailer { public String send(String to, String text) { return "[log] to " + to + ": " + text; } }
static class SmtpMailer implements Mailer { public String send(String to, String text) { return "[smtp] to " + to + ": " + text; } }
static class Notifier {
final Mailer mailer;
Notifier(Mailer mailer) { this.mailer = mailer; }
String welcome(String email) { return mailer.send(email, "Welcome!"); }
}
interface Provider { void register(Container app); void boot(Container app); }
static class MailServiceProvider implements Provider {
public void register(Container app) {
// Only bind here. The factory reads the config when the mailer is first needed.
app.singleton("mailer", c -> "local".equals(c.<Map<String, String>>make("config").get("env")) ? new LogMailer() : new SmtpMailer());
}
public void boot(Container app) {}
}
static class NotificationServiceProvider implements Provider {
public void register(Container app) {
app.bind("notifier", c -> new Notifier(c.make("mailer")));
}
public void boot(Container app) {
// Every provider has registered by now, so using other services is safe.
System.out.println("boot: notifier uses " + app.<Notifier>make("notifier").mailer.getClass().getSimpleName());
}
}
static Container bootstrap(String env) {
Container app = new Container();
app.singleton("config", c -> Map.of("env", env));
List<Provider> providers = List.of(new NotificationServiceProvider(), new MailServiceProvider());
for (Provider provider : providers) provider.register(app);
for (Provider provider : providers) provider.boot(app);
return app;
}
public static void main(String[] args) {
Container app = bootstrap("local");
System.out.println(app.<Notifier>make("notifier").welcome("[email protected]"));
System.out.println("same mailer: " + (app.make("mailer") == app.make("mailer") ? "yes" : "no"));
System.out.println("same notifier: " + (app.make("notifier") == app.make("notifier") ? "yes" : "no"));
System.out.println(bootstrap("production").<Notifier>make("notifier").welcome("[email protected]"));
}
}
Output
boot: notifier uses LogMailer [log] to [email protected]: Welcome! same mailer: yes same notifier: no boot: notifier uses SmtpMailer [smtp] to [email protected]: Welcome!
Run this on your computer — see “Get Started” for setup.
<?php
class Container {
private array $bindings = [];
private array $instances = [];
public function bind(string $name, callable $factory): void { $this->bindings[$name] = [$factory, false]; }
public function singleton(string $name, callable $factory): void { $this->bindings[$name] = [$factory, true]; }
public function make(string $name): mixed {
if (array_key_exists($name, $this->instances)) return $this->instances[$name];
[$factory, $shared] = $this->bindings[$name];
$obj = $factory($this);
if ($shared) $this->instances[$name] = $obj;
return $obj;
}
}
interface Mailer { public function send(string $to, string $text): string; }
class LogMailer implements Mailer { public function send(string $to, string $text): string { return "[log] to $to: $text"; } }
class SmtpMailer implements Mailer { public function send(string $to, string $text): string { return "[smtp] to $to: $text"; } }
class Notifier {
public function __construct(public Mailer $mailer) {}
public function welcome(string $email): string { return $this->mailer->send($email, "Welcome!"); }
}
interface Provider { public function register(Container $app): void; public function boot(Container $app): void; }
class MailServiceProvider implements Provider {
public function register(Container $app): void {
// Only bind here. The factory reads the config when the mailer is first needed.
$app->singleton("mailer", fn(Container $c) => $c->make("config")["env"] === "local" ? new LogMailer() : new SmtpMailer());
}
public function boot(Container $app): void {}
}
class NotificationServiceProvider implements Provider {
public function register(Container $app): void {
$app->bind("notifier", fn(Container $c) => new Notifier($c->make("mailer")));
}
public function boot(Container $app): void {
// Every provider has registered by now, so using other services is safe.
echo "boot: notifier uses ", get_class($app->make("notifier")->mailer), "\n";
}
}
function bootstrap(string $env): Container {
$app = new Container();
$app->singleton("config", fn() => ["env" => $env]);
$providers = [new NotificationServiceProvider(), new MailServiceProvider()];
foreach ($providers as $provider) $provider->register($app);
foreach ($providers as $provider) $provider->boot($app);
return $app;
}
$app = bootstrap("local");
echo $app->make("notifier")->welcome("[email protected]"), "\n";
echo "same mailer: ", $app->make("mailer") === $app->make("mailer") ? "yes" : "no", "\n";
echo "same notifier: ", $app->make("notifier") === $app->make("notifier") ? "yes" : "no", "\n";
echo bootstrap("production")->make("notifier")->welcome("[email protected]"), "\n";
Output
boot: notifier uses LogMailer [log] to [email protected]: Welcome! same mailer: yes same notifier: no boot: notifier uses SmtpMailer [smtp] to [email protected]: Welcome!
bind gave a new notifier each time, singleton shared one mailer, and switching the environment swapped the mailer without touching Notifier.
In Laravel the same ideas look like this. The container builds Notifier by reading its constructor, so only the interface needs a binding:
// app/Providers/MailServiceProvider.php (listed in bootstrap/providers.php)
class MailServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Mailer::class, fn ($app) =>
$app->environment('local') ? new LogMailer() : new SmtpMailer(config('mail.smtp')));
}
public function boot(): void
{
// Safe to use other services here: routes, event listeners, view composers.
}
}
// Anywhere: constructor injection, resolved by the container
class SignupController
{
public function __construct(private Notifier $notifier) {}
}
When to use it
- An application with many services whose wiring changes by environment (local, test, production) or by configuration.
- Objects that must be shared for the life of the app or request (connection pools, clients) next to ones that must be fresh.
- Packages and modules that need to add their own services, routes and listeners without editing the application: each ships a provider.
- Tests that swap a real service for a fake in one place (
$this->app->instance(Mailer::class, new FakeMailer)).
When not to use it
- Small scripts and libraries. Passing dependencies by hand is clearer than a container.
- Service locator use: calling
app()->make(...)deep inside business code hides what a class depends on. Ask for dependencies in the constructor and let the container supply them. - Doing real work in
register(querying the database, resolving other services). It runs before other providers have registered; put that inboot. - Binding everything by hand when auto-wiring already resolves concrete classes. Bind interfaces and things that need configuration.
You've already used it
- Laravel: the service container (
bind,singleton,make, auto-wiring, contextual binding), and service providers withregister()andboot(), listed inbootstrap/providers.php(inconfig/app.phpbefore Laravel 11). Providers can be deferred until one of their services is first needed. - Symfony: the DependencyInjection component with autowiring; bundles play the role of providers.
- Spring: the
ApplicationContextwith@Configurationand@Beanmethods and@Componentscanning. - ASP.NET Core:
builder.Services.AddSingleton / AddScoped / AddTransientinProgram.cs. - NestJS and Angular: modules declare providers; injectors resolve constructor dependencies.
Key points
- A service container maps names or interfaces to factories and builds objects with their dependencies:
bindmakes new ones,singletonshares one. - Service providers group a feature's bindings; the framework calls every
register, then everyboot, sobootcan rely on anything. - Prefer constructor injection; avoid using the container as a service locator.
- Further reading: Laravel: Service Container and Laravel: Service Providers.
Practice questions
What is the difference between bind and singleton in a service container?
Answer
bind registers a factory that runs every time the service is resolved, so each caller gets a new object. singleton runs the factory once and returns that same instance afterwards, which suits shared resources like connection pools and API clients.
Why should a service provider's register method only bind things into the container?
Answer
Providers are registered one after another. During register, services from providers that haven't registered yet don't exist, so resolving them can fail or pick up the wrong binding. Work that uses other services (routes, listeners, view composers) goes in boot, which runs after every provider has registered.
What is auto-wiring, and when do you still need an explicit binding?
Answer
Auto-wiring is the container reading a class's constructor type hints (with reflection) and building those dependencies itself, so concrete classes need no configuration. You still bind interfaces to the implementation to use, services that need configuration (API keys, environment-specific choices) and services that must be shared as singletons.
Open in the app to mark lessons complete, take notes and track your progress.