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 instancesInterfaceFunction, 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")
  );
}

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.

See also