Features
Composable, reusable application modules
Features are self-contained modules that bundle services, controllers, and configuration into reusable packages. They enable you to build modular applications and share functionality across projects.
What is a Feature?
A feature is a function that returns services and controllers. Features can have configuration, depend on other features, and be distributed as npm packages:
import { createFeature, createClusterBuilder } from '@justscale/core';
import { LoggerService } from './logger-service';
// Simple feature with no config
export const LoggingFeature = createFeature({
build: () => ({
services: [LoggerService],
controllers: [],
}),
});
// Use in app
const app = createClusterBuilder()
.use(LoggingFeature())
.build()
.compile();Creating a Feature
Use createFeature to define a feature. The build function returns services and controllers that the feature provides:
import { createFeature, createClusterBuilder } from '@justscale/core';
import { UserRepository, UserService, AuthService } from './services';
import { UsersController, AuthController } from './controllers';
export const UserFeature = createFeature({
build: () => ({
services: [
UserRepository,
UserService,
AuthService,
],
controllers: [
UsersController,
AuthController,
],
}),
});
// Use it
const app = createClusterBuilder()
.use(UserFeature())
.build()
.compile();Features with Configuration
Features can accept configuration using Zod schemas. Configuration is validated and type-safe:
import { createFeature } from '@justscale/core';
import { z } from 'zod';
export const AuthFeature = createFeature({
config: z.object({
sessionTtl: z.number().default(3600),
jwtSecret: z.string(),
refreshTokenTtl: z.number().default(86400),
}),
build: (config) => {
// config is typed and validated
const tokenService = createTokenService({
secret: config.jwtSecret,
ttl: config.sessionTtl,
});
return {
services: [tokenService, SessionService],
controllers: [AuthController],
};
},
});
// Configure when using
const app = createClusterBuilder()
.use(AuthFeature({
jwtSecret: process.env.JWT_SECRET,
sessionTtl: 7200,
}))
.build()
.compile();Feature Dependencies
Features can depend on other features using requires. Dependencies are automatically resolved:
import { createFeature } from '@justscale/core';
// Base feature
export const DatabaseFeature = createFeature({
build: () => ({
services: [DatabaseService],
controllers: [],
}),
});
// Feature that depends on DatabaseFeature
export const UserFeature = createFeature({
requires: {
db: DatabaseFeature,
},
build: (config, { db }) => {
// db.config, db.services, db.controllers available
return {
services: [UserRepository, UserService],
controllers: [UsersController],
};
},
});
// Only need to include UserFeature - DatabaseFeature is auto-included
const app = createClusterBuilder()
.use(UserFeature())
.build()
.compile();Composing Features
Build complex applications by composing multiple features:
import { createFeature } from '@justscale/core';
import { z } from 'zod';
// Base features
export const DatabaseFeature = createFeature({
config: z.object({
url: z.string(),
}),
build: (config) => ({
services: [createDatabaseService(config.url)],
controllers: [],
}),
});
export const AuthFeature = createFeature({
requires: { db: DatabaseFeature },
config: z.object({
jwtSecret: z.string(),
}),
build: (config, { db }) => ({
services: [TokenService, SessionService],
controllers: [AuthController],
}),
});
export const PaymentFeature = createFeature({
requires: {
auth: AuthFeature,
db: DatabaseFeature,
},
config: z.object({
stripeKey: z.string(),
}),
build: (config, { auth, db }) => ({
services: [StripeService, PaymentService],
controllers: [PaymentController, WebhookController],
}),
});
// All dependencies automatically resolved
const app = createClusterBuilder()
.use(DatabaseFeature({ url: 'postgresql://...' }))
.use(AuthFeature({ jwtSecret: process.env.JWT_SECRET }))
.use(PaymentFeature({ stripeKey: process.env.STRIPE_KEY }))
.build()
.compile();Automatic Dependency Resolution
If you include a feature that has dependencies, JustScale automatically includes and configures the required features with their default config:
// PaymentFeature requires AuthFeature, which requires DatabaseFeature
const app = createClusterBuilder()
// Only specify PaymentFeature - others auto-included with defaults
.use(PaymentFeature({ stripeKey: process.env.STRIPE_KEY }))
.build()
.compile();
// Or override specific configs
const app2 = createClusterBuilder()
.use(DatabaseFeature({ url: 'postgresql://custom' })) // Override
.use(PaymentFeature({ stripeKey: process.env.STRIPE_KEY }))
// AuthFeature auto-included with defaults
.build()
.compile();Service Bindings
Features can bind abstract tokens to concrete implementations using tuple syntax:
import { createFeature } from '@justscale/core';
// Abstract interface
abstract class Logger {
abstract log(message: string): void;
}
// Concrete implementation
const ConsoleLogger = createService({
inject: {},
factory: () => ({
log: (message: string) => console.log(message),
}),
});
// Feature binds abstract to concrete
export const LoggingFeature = createFeature({
build: () => ({
services: [
[Logger, ConsoleLogger], // Bind Logger token to ConsoleLogger
],
controllers: [],
}),
});
// Other services can now inject Logger
const UserService = createService({
inject: { logger: Logger }, // Uses abstract token
factory: ({ logger }) => ({
create: (user) => {
logger.log('Creating user');
return user;
},
}),
});Accessing Feature Dependencies
The build function receives resolved dependencies as the second parameter:
export const NotificationFeature = createFeature({
requires: {
auth: AuthFeature,
db: DatabaseFeature,
},
config: z.object({
emailFrom: z.string(),
}),
build: (config, { auth, db }) => {
// Access dependency configs
console.log('Auth session TTL:', auth.config.sessionTtl);
console.log('Database URL:', db.config.url);
// Access dependency services
const tokenService = auth.services.find(s => s === TokenService);
return {
services: [
createEmailService(config.emailFrom),
NotificationService,
],
controllers: [NotificationController],
};
},
});Sharing Features
Features are perfect for sharing functionality across projects. Publish them as npm packages:
import { createFeature } from '@justscale/core';
import { z } from 'zod';
export const AuthFeature = createFeature({
config: z.object({
jwtSecret: z.string(),
sessionTtl: z.number().default(3600),
}),
build: (config) => ({
services: [TokenService, SessionService],
controllers: [AuthController],
}),
});Official Features
JustScale provides official features for common functionality:
@justscale/auth- Authentication and sessions@justscale/feature-openapi- OpenAPI documentation generation@justscale/feature-shell- Interactive CLI shell
import { createClusterBuilder } from '@justscale/core';
import { AuthFeature } from '@justscale/auth';
import { OpenApiFeature } from '@justscale/feature-openapi';
const app = createClusterBuilder()
.use(AuthFeature())
.use(OpenApiFeature({
title: 'My API',
version: '1.0.0',
}))
.build()
.compile();Feature Configuration Defaults
Use Zod's .default() to provide sensible defaults for feature configuration:
export const AuthFeature = createFeature({
config: z.object({
jwtSecret: z.string(), // Required
sessionTtl: z.number().default(3600), // Optional with default
refreshTokenTtl: z.number().default(86400), // Optional with default
allowRegistration: z.boolean().default(true), // Optional with default
}),
build: (config) => ({
services: [TokenService, SessionService],
controllers: [AuthController],
}),
});
// Only need to provide required config
const app = createClusterBuilder()
.use(AuthFeature({ jwtSecret: 'secret' })) // Uses defaults for other fields
.build()
.compile();Circular Dependencies
JustScale detects circular feature dependencies at runtime and throws an error:
const FeatureA = createFeature({
requires: { b: FeatureB },
build: () => ({ services: [], controllers: [] }),
});
const FeatureB = createFeature({
requires: { a: FeatureA },
build: () => ({ services: [], controllers: [] }),
});
// Error: Circular dependency detected in features
const app = createClusterBuilder()
.use(FeatureA())
.build();Best Practices
- One feature per domain - Group related functionality together
- Explicit dependencies - Use
requiresto declare feature dependencies - Sensible defaults - Provide defaults for all optional config fields
- Document configuration - Use Zod's
.describe()for documentation - Version features independently - When sharing as packages, use semantic versioning
- Keep features focused - A feature should represent one clear capability