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:

logging-feature.tsTypeScript
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:

user-feature.tsTypeScript
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:

auth-feature.tsTypeScript
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:

feature-dependencies.tsTypeScript
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:

composing-features.tsTypeScript
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:

auto-resolution.tsTypeScript
// 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:

service-bindings.tsTypeScript
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:

accessing-dependencies.tsTypeScript
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:

Files
@mycompany/auth-feature/index.tsTypeScript
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
official-features.tsTypeScript
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:

config-defaults.tsTypeScript
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:

circular-deps.tsTypeScript
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 requires to 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