[data-reveal]{opacity:1!important;transform:none!important}
Concepts

Interfaces

Interfaces define the contracts that modules use to communicate — declaring functions, events, and registrations without tying them to any specific implementation.

What Is an Interface

An interface is a contract that defines how modules communicate. The contract declares functions, events, or registrations without implementing them. Producers fulfill the contract, and consumers call the contract — neither side knows about the other directly.

The AntelopeJS core resolves which module fulfills each interface at runtime. A module that needs email delivery imports the email interface, not the email module. Modules depend on contracts rather than on each other, which keeps them independent and interchangeable.

What an Interface Contains

An interface file is more than type definitions and proxy declarations. The file serves as the complete public surface that both producers and consumers rely on.

An interface can contain:

  • Type definitions — TypeScript types and interfaces shared between producer and consumer
  • Proxy instances — InterfaceFunction, EventProxy, and RegisteringProxy declarations that define the contract points
  • Wrapper functions — Public API functions that call the underlying proxies, providing a cleaner API surface
  • Decorator factories — Decorators used by consumers (e.g., @Controller, @Authentication)
  • Utility code — Helper classes, query builders, metadata management — any generic code that stays the same regardless of the implementation

The following example shows an interface file with shared types, internal proxies, and public wrapper functions:

src/interface/index.ts
import { InterfaceFunction } from "@antelopejs/interface-core";

// Types shared between producer and consumer
export interface EmailParams {
  to: string;
  subject: string;
  body: string;
}

export interface EmailResult {
  messageId: string;
  status: string;
}

// Internal proxies — the actual contract points
export namespace internal {
  export const sendEmailProxy =
    InterfaceFunction<(params: EmailParams) => Promise<EmailResult>>();
  export const getStatusProxy =
    InterfaceFunction<(messageId: string) => Promise<string>>();
}

// Public API — clean wrapper functions for consumers
export function SendEmail(params: EmailParams): Promise<EmailResult> {
  return internal.sendEmailProxy(params);
}

export function GetEmailStatus(messageId: string): Promise<string> {
  return internal.getStatusProxy(messageId);
}

Consumers call SendEmail(params) instead of reaching for the proxy directly. The wrapper functions provide a stable, readable API while the proxies handle the runtime resolution behind the scenes.

Interface files can contain generic code that does not change between implementations. Decorator factories, utility functions, and helper classes belong in the interface when all implementations share the same behavior.

Embedded vs Standalone Interfaces

Interfaces take one of two forms depending on how many modules need to implement them.

Embedded Interface

An embedded interface lives inside the module that implements the contract, in a src/interface/ directory. The module contains both the contract and its implementation. Embedded interfaces are the natural starting point when you create a new module with a new interface.

my-module/
├── src/
│   ├── index.ts
│   ├── interface/
│   │   └── index.ts          # Interface contract
│   └── implementations/
│       └── index.ts          # Implementation

The module entry point connects the contract to its implementation:

src/index.ts
import { ImplementInterface } from "@antelopejs/interface-core";

export async function construct() {
  ImplementInterface(
    await import("./interface"),
    await import("./implementations"),
  );
}

Standalone Interface Package

When an interface needs multiple implementations, extract the contract into its own npm package. The standalone interface package contains only the contract — types, proxies, wrappers, and utilities. Each implementing module lives in a separate package and declares the interface in its implements field.

@antelopejs/interface-email/     # Standalone interface package
├── src/
│   └── index.ts                 # Types, proxies, wrappers

@antelopejs/email-brevo/         # Implementation module A
├── src/
│   ├── index.ts
│   └── implementations/
│       └── email/
│           └── index.ts

@antelopejs/email-sendgrid/      # Implementation module B
├── src/
│   ├── index.ts
│   └── implementations/
│       └── email/
│           └── index.ts

Each implementing module references the external interface package in its configuration:

package.json
{
  "antelopeJs": {
    "implements": ["@antelopejs/interface-email"]
  }
}

The module entry point imports the interface from the standalone package instead of a local path:

src/index.ts
import { ImplementInterface } from "@antelopejs/interface-core";

export async function construct() {
  ImplementInterface(
    await import("@antelopejs/interface-email"),
    await import("./implementations/email"),
  );
}

How the Core Tells the Two Kinds Apart

Both an interface package and a runtime module carry an antelopeJs key in their package.json, so the key alone says nothing. The discriminator is antelopeJs.implements:

antelopeJs.implementsThe package isDepending on it means
absent, or []an interface packagethe contract must be provided by some loaded module
lists other packagesa runtime modulea plain package dependency
lists only the package itselfa runtime module that ships its own interfacethe contract must be provided

This matters because the core refuses to start when a module depends on an interface package nobody implements. Without the rule, a module that depends on another runtime module — a playground depending on the product it exercises, say — fails startup with Unresolved interface dependencies: <module> requires <runtime package>, and the only way out is to make the runtime package declare itself in its own implements. Declare implements for what a module actually implements; a package never has to name itself to be depended upon.

A package loaded as a module of the project is never treated as an interface package, whatever its manifest says.

When to Extract

Start with an embedded interface and extract to a standalone package when the situation calls for it. Three signals indicate that extraction makes sense:

  • A second module needs to implement the same interface
  • Consumers should depend on the contract without depending on a specific implementation
  • The interface becomes part of the public ecosystem and needs its own release cycle
Start with an embedded interface. Extract to a standalone package only when a second implementation appears. Premature extraction adds complexity without benefit.

Complete Example

The following example walks through a storage interface from declaration to consumption. It covers three files: the interface contract, the producer module that implements it, and a consumer module that uses it.

Step 1 — Declare the interface

The interface defines a key-value storage contract with get and set operations. InterfaceFunction creates typed proxies, and wrapper functions provide the public API.

src/interface/index.ts
import { InterfaceFunction } from "@antelopejs/interface-core";

// Shared types
export interface StorageEntry {
  key: string;
  value: string;
  updatedAt: number;
}

// Internal proxies — the contract points
export namespace internal {
  export const getProxy =
    InterfaceFunction<(key: string) => Promise<StorageEntry | null>>();
  export const setProxy =
    InterfaceFunction<(key: string, value: string) => Promise<StorageEntry>>();
}

// Public API — consumers call these functions
export function Get(key: string): Promise<StorageEntry | null> {
  return internal.getProxy(key);
}

export function Set(key: string, value: string): Promise<StorageEntry> {
  return internal.setProxy(key, value);
}

Step 2 — Implement in the producer module

The producer module exports a namespace that matches the interface's internal namespace. The core binds each proxy to the corresponding function by matching names.

src/implementations/index.ts
import type { StorageEntry } from "../interface";

const store = new Map<string, StorageEntry>();

export namespace internal {
  export async function getProxy(key: string): Promise<StorageEntry | null> {
    return store.get(key) ?? null;
  }

  export async function setProxy(
    key: string,
    value: string,
  ): Promise<StorageEntry> {
    const entry: StorageEntry = { key, value, updatedAt: Date.now() };
    store.set(key, entry);
    return entry;
  }
}

The module entry point connects the interface to its implementation during the construct phase.

src/index.ts
import { ImplementInterface } from "@antelopejs/interface-core";

export async function construct() {
  ImplementInterface(
    await import("./interface"),
    await import("./implementations"),
  );
}

Step 3 — Consume from another module

A consumer module imports the wrapper functions from the interface and calls them directly. The consumer has no dependency on the producer module — only on the contract.

src/index.ts
import { Get, Set } from "@your-org/storage-module/interface";

export async function start() {
  await Set("greeting", "hello world");

  const entry = await Get("greeting");
  if (entry) {
    console.log(entry.value); // "hello world"
  }
}

If the proxy has no implementation attached yet when the consumer calls Get or Set, the call queues automatically and resolves once a producer module attaches its implementation. This queuing behavior means module load order does not matter.

Interface Instances

The core evaluates an interface package once for each provider it is bound to. A module bound to mongodb-module and a module bound to postgres-module each import their own copy of @antelopejs/interface-database; modules bound the same way share one copy, so a project with one provider per interface has one copy of each, as if nothing happened.

Each copy's proxies belong to exactly one provider. Objects built from a copy (a query, a schema) carry that copy's proxies, so they reach that provider wherever they end up. A standalone interface built on others is copied the same way, once per combination of bindings it is built on. See Interface Bindings for how a module is bound.

Two instances of one module package, configured apart, are also evaluated separately, dependencies included, so neither sees the other's module-level state.

See also