Skip to content

Dependency Injection

Storion provides a lightweight dependency injection (DI) system through containers and services. This enables loose coupling, testability, and clean architecture.

Overview

The DI system consists of:

  • Containers — Manage store and service instances
  • Services — Non-reactive utilities (API clients, loggers, etc.)
  • Stores — Reactive state with automatic dependency resolution
ts
import { container, store, service } from 'storion';

// Define a service
const apiService = service(() => ({
  get: (url: string) => fetch(url).then(r => r.json()),
}));

// Define a store that depends on the service
const userStore = store({
  name: 'user',
  state: { user: null },
  setup({ state, get }) {
    const api = get(apiService);
    return {
      fetchUser: async (id: string) => {
        state.user = await api.get(`/api/users/${id}`);
      },
    };
  },
});

// Container resolves all dependencies
const app = container();
const [state, actions] = app.get(userStore);

Services vs Stores

AspectStoreService
StateHas reactive stateNo state (or non-reactive)
PurposeDomain data & logicInfrastructure & utilities
UpdatesChanges trigger re-rendersNo reactivity
LifecycleManaged by containerSingleton per container
ExamplesUser, Cart, UIAPI client, Logger, Analytics

Defining Services

Simple Service

Services are factory functions that return an object:

ts
function apiService() {
  const baseUrl = '/api';
  
  return {
    get: (path: string) => fetch(`${baseUrl}${path}`).then(r => r.json()),
    post: (path: string, data: unknown) => fetch(`${baseUrl}${path}`, {
      method: 'POST',
      body: JSON.stringify(data),
    }).then(r => r.json()),
  };
}

Typed Services

Services are plain functions — use explicit return types for best TypeScript support:

ts
interface ApiService {
  get: (path: string) => Promise<unknown>;
  post: (path: string, data: unknown) => Promise<unknown>;
}

// Annotate return type for full type inference
const apiService = (): ApiService => ({
  get: (path) => fetch(path).then(r => r.json()),
  post: (path, data) => fetch(path, {
    method: 'POST',
    body: JSON.stringify(data),
  }).then(r => r.json()),
});

Service with Dependencies

Services can depend on other services:

ts
function userApiService(resolver) {
  const api = resolver.get(apiService);
  const logger = resolver.get(loggerService);
  
  return {
    getUser: async (id: string) => {
      logger.info(`Fetching user ${id}`);
      return api.get(`/users/${id}`);
    },
    createUser: async (data: UserData) => {
      logger.info('Creating user');
      return api.post('/users', data);
    },
  };
}

Parameterized Services

Use create() for services that need parameters:

ts
function createLogger(resolver, namespace: string) {
  return {
    info: (msg: string) => console.log(`[${namespace}] ${msg}`),
    error: (msg: string) => console.error(`[${namespace}] ${msg}`),
    warn: (msg: string) => console.warn(`[${namespace}] ${msg}`),
  };
}

// In store setup
setup({ create }) {
  const logger = create(createLogger, 'user-store');
  // ...
}

Using Services in Stores

get() — Shared Instance

Use get() for singleton services shared across stores:

ts
const userStore = store({
  name: 'user',
  state: { profile: null, loading: false },
  setup({ state, get }) {
    // get() returns the same instance every time
    const api = get(userApiService);
    
    return {
      fetchProfile: async (id: string) => {
        state.loading = true;
        state.profile = await api.getUser(id);
        state.loading = false;
      },
    };
  },
});

create() — Fresh Instance

Use create() for unique instances or parameterized services:

ts
const orderStore = store({
  name: 'order',
  state: { items: [] },
  setup({ state, get, create }) {
    const api = get(apiService);           // Shared
    const logger = create(createLogger, 'orders');  // Unique
    
    return {
      addItem: (item: OrderItem) => {
        logger.info(`Adding item: ${item.name}`);
        state.items.push(item);
      },
    };
  },
});

Store Dependencies

Stores can depend on other stores:

ts
const cartStore = store({
  name: 'cart',
  state: { items: [] },
  setup({ state, get }) {
    // Depend on user store
    const [userState] = get(userStore);
    
    return {
      checkout: async () => {
        if (!userState.user) {
          throw new Error('Must be logged in');
        }
        // Use user data for checkout
      },
    };
  },
});

Dependency Order

When using get() for stores, declare dependencies at the top of setup. The container resolves them in order.

Containers

Creating Containers

ts
import { container } from 'storion';

const app = container();

// Get store instances
const [userState, userActions] = app.get(userStore);
const [cartState, cartActions] = app.get(cartStore);

// Get service instances
const api = app.get(apiService);

Container Lifecycle

ts
// Create
const app = container();

// Use
const instance = app.get(myStore);

// Delete specific instance
app.delete(myStore);

// Clear all instances
app.clear();

// Dispose container (cleanup subscriptions)
app.dispose();

Multiple Containers

Create separate containers for isolation:

ts
// Main app container
const app = container();

// Feature-specific container
const featureContainer = container();

// Testing container
const testContainer = container();

Dependency Injection Patterns

Repository Pattern

ts
// Repository interface
interface UserRepository {
  findById(id: string): Promise<User | null>;
  save(user: User): Promise<void>;
}

// Implementation
const userRepository = service<UserRepository>((resolver) => {
  const api = resolver.get(apiService);
  
  return {
    findById: (id) => api.get(`/users/${id}`),
    save: (user) => api.post('/users', user),
  };
});

// Store uses repository
const userStore = store({
  name: 'user',
  state: { user: null },
  setup({ state, get }) {
    const repo = get(userRepository);
    
    return {
      loadUser: async (id: string) => {
        state.user = await repo.findById(id);
      },
    };
  },
});

Factory Pattern

ts
// Factory service with parameters
function createNotificationService(resolver, options: NotificationOptions) {
  const api = resolver.get(apiService);
  
  return {
    send: async (message: string) => {
      if (options.enabled) {
        await api.post('/notifications', {
          message,
          channel: options.channel,
        });
      }
    },
  };
}

// Use with create()
setup({ create }) {
  const notifications = create(createNotificationService, {
    enabled: true,
    channel: 'email',
  });
}

Decorator Pattern

ts
// Base service
const baseApi = service(() => ({
  fetch: (url: string) => fetch(url),
}));

// Decorated service with logging
const loggingApi = service((resolver) => {
  const api = resolver.get(baseApi);
  const logger = resolver.get(loggerService);
  
  return {
    fetch: async (url: string) => {
      logger.info(`Fetching: ${url}`);
      const result = await api.fetch(url);
      logger.info(`Completed: ${url}`);
      return result;
    },
  };
});

Testing with DI

Mocking Services

ts
import { container, service } from 'storion';

// Mock service
const mockApi = service(() => ({
  get: vi.fn().mockResolvedValue({ id: '1', name: 'Test' }),
  post: vi.fn().mockResolvedValue({ success: true }),
}));

// Test
it('should fetch user', async () => {
  const testContainer = container();
  
  // Override the real service with mock
  // by creating the mock first
  testContainer.get(mockApi);
  
  const [state, actions] = testContainer.get(userStore);
  await actions.fetchUser('1');
  
  expect(state.user).toEqual({ id: '1', name: 'Test' });
});

Isolated Test Containers

ts
function createTestContainer() {
  return container({
    middleware: [
      // Test-specific middleware
    ],
  });
}

describe('UserStore', () => {
  let app: Container;
  
  beforeEach(() => {
    app = createTestContainer();
  });
  
  afterEach(() => {
    app.dispose();
  });
  
  it('should work', () => {
    const [state, actions] = app.get(userStore);
    // ...
  });
});

Best Practices

1. Declare Dependencies Early

ts
// ✅ Good - declare at top of setup
setup({ get, create }) {
  const api = get(apiService);
  const logger = create(createLogger, 'store');
  
  return {
    action: () => {
      // Use api and logger
    },
  };
}

// ❌ Bad - late declaration
setup({ get }) {
  return {
    action: () => {
      const api = get(apiService); // Will throw!
    },
  };
}

2. Use Typed Services

ts
// ✅ Good - typed service
const apiService = service<ApiService>(() => ({
  // TypeScript knows the shape
}));

// ❌ Avoid - untyped
function apiService() {
  return { /* no type info */ };
}

3. Single Responsibility

ts
// ✅ Good - focused services
const authService = service(() => ({ login, logout }));
const userService = service(() => ({ getUser, updateUser }));

// ❌ Avoid - god service
const everythingService = service(() => ({
  login, logout, getUser, updateUser, fetchProducts, ...
}));

Next Steps

Released under the MIT License.