Plugin Architecture
Karin is built on a modular architecture that encourages extensibility. While the core framework provides the essential building blocks (Router, DI Container, Exception Handler), sophisticated applications often require integration with external systems—Databases, Message Queues, Authentication Providers, and Monitoring Tools.
Plugins are the mechanism to bridge these external systems into the application lifecycle. Unlike simple middleware, which only operates during a request, a Plugin operates at the Application Level. It participates in the bootstrap phase, the initialization phase, and the shutdown phase.
Concept: Specialized Contractors
If the Core Framework is the General Contractor building a house:
- Plugins are the Specialized Contractors (Electricians, Plumbers, HVAC tech).
- Install Phase: They arrive on site, unpack their tools, and agree on the blueprints (
install). - Init Phase: They connect the wires and turn on the water main (
onPluginInit). - Destroy Phase: They shut down the power and clean up debris when the project ends (
onPluginDestroy).
The KarinPlugin Interface
A plugin is simply a class that implements the KarinPlugin interface. This interface defines three optional lifecycle hooks.
export interface KarinPlugin {
name: string;
install(app: KarinApplication): void | Promise<void>;
onPluginInit?(): void | Promise<void>;
onPluginDestroy?(): void | Promise<void>;
}1. install(app): The Setup Phase
This method is called synchronously (or asynchronously) immediately when KarinFactory.create() is invoked. At this stage, the HTTP server is not yet listening, and the Dependency Injection (DI) container might not be fully hydrated.
Use cases for install:
- Registering Global Middleware: You can modify the
appinstance directly to add Hono/Express compatible middleware. - Registering Global Filters/Guards: You can enforce security policies that apply to the entire app.
- Configuration Validation: Check if required environment variables are present before the app even tries to boot.
2. onPluginInit(): The Connection Phase
This method is called after the application has fully compiled but before it starts accepting traffic. The DI container is fully ready.
Use cases for onPluginInit:
- Database Connections: Connect to MongoDB, Redis, or Postgres. If this fails, the application boot will halt (Fail Fast).
- Service Registration: Register external clients (like an AWS SDK or Stripe Client) into the DI container so other services can inject them.
- Background Tasks: Start cron jobs or message queue consumers.
3. onPluginDestroy(): The Cleanup Phase
This method is called when the application receives a termination signal (SIGTERM, SIGINT). It allows for a graceful shutdown.
Use cases for onPluginDestroy:
- Closing Connections: Disconnect from databases to prevent hanging connections.
- Flushing Logs: Ensure all pending telemetry is sent to the ingestion server.
- Stopping Timers: Clear intervals to allow the process to exit cleanly.
Developing a Custom Plugin
Let's build a practical example: a Feature Flag Plugin. This plugin will fetch feature flags from an external API at startup and expose them to the rest of the application.
Step 1: Define the Plugin Class
We implement the KarinPlugin interface. We accept configuration options in the constructor to make the plugin reusable across different environments.
import { KarinPlugin, KarinApplication, Logger } from "@project-karin/core";
export class FeatureFlagPlugin implements KarinPlugin {
name = "FeatureFlagPlugin";
private logger = new Logger("FeatureFlags");
private flags: Record<string, boolean> = {};
constructor(private readonly config: { apiUrl: string; refreshInterval: number }) {}
async install(app: KarinApplication) {
this.logger.log("🔧 Registering Feature Flag System...");
// We could attach a global middleware here to inject flags into every request if needed
}
async onPluginInit() {
this.logger.log(`🚀 Fetching flags from ${this.config.apiUrl}...`);
try {
// Simulate fetching flags
this.flags = await this.fetchFlags();
this.logger.log(`Loaded ${Object.keys(this.flags).length} flags.`);
} catch (error) {
// If flags are critical, we throw here to stop the app from starting
throw new Error(`Failed to load feature flags: ${error.message}`);
}
}
private async fetchFlags() {
// Mock implementation
return { "new-ui": true, "beta-access": false };
}
}Step 2: Registering Services (Dependency Injection)
Plugins often need to expose their functionality to Controllers and Services. We can use the global container to register a provider.
import { container } from "@project-karin/core";
// Inside onPluginInit()
async onPluginInit() {
// ... fetch flags ...
// Register the flags object (or a service) as a reachable dependency
container.registerInstance("FEATURE_FLAGS", this.flags);
}Now, any service can inject these flags:
@Service()
export class UserService {
constructor(@Inject("FEATURE_FLAGS") private flags: Record<string, boolean>) {}
getDashboard() {
if (this.flags["new-ui"]) {
return "Modern Dashboard";
}
return "Legacy Dashboard";
}
}Step 3: Graceful Shutdown
If our plugin started a polling interval to refresh flags, we must stop it to prevent the process from hanging.
export class FeatureFlagPlugin implements KarinPlugin {
private timer: Timer;
async onPluginInit() {
this.timer = setInterval(() => this.refresh(), this.config.refreshInterval);
}
async onPluginDestroy() {
this.logger.log("🛑 Stopping flag refresh timer...");
clearInterval(this.timer);
}
}Official vs. Ecosystem Plugins
Karin maintains a distinction between Core Plugins and Ecosystem Plugins.
- Core Plugins: Maintained by the Karin team, guaranteed to be compatible with the current version. (e.g.,
@project-karin/config,@project-karin/redis). - Ecosystem Plugins: Community-contributed. Since plugins essentially have root access to the application lifecycle, always audit community plugins before installing them in production.
