Module Development

Exporting Interfaces

Create interface contracts with typed proxies, implement them with concrete logic, and extract standalone packages when multiple implementations appear.

Creating an Embedded Interface

An embedded interface lives inside the module that implements it. The module contains both the contract definition and the concrete logic, organized into separate directories.

my-module/
├── src/
│   ├── index.ts
│   ├── interface/
│   │   └── index.ts
│   └── implementations/
│       └── index.ts

The interface/ directory holds the contract — types, proxies, and public wrapper functions. The implementations/ directory holds the concrete logic that fulfills that contract.

Step 1 — Define the Interface Contract

The interface file declares types, internal proxy functions, and public wrapper functions that consumers call. Proxies live inside a namespace internal block, and wrapper functions provide the clean public API.

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

export interface User {
  id: string;
  name: string;
  email: string;
}

// Internal proxies — bound to an implementation at runtime
export namespace internal {
  export const getUserProxy = InterfaceFunction<(id: string) => Promise<User>>();
  export const createUserProxy = InterfaceFunction<(name: string, email: string) => Promise<User>>();
  export const deleteUserProxy = InterfaceFunction<(id: string) => Promise<void>>();
}

// Public API — consumers import and call these functions
export function GetUser(id: string): Promise<User> {
  return internal.getUserProxy(id);
}

export function CreateUser(name: string, email: string): Promise<User> {
  return internal.createUserProxy(name, email);
}

export function DeleteUser(id: string): Promise<void> {
  return internal.deleteUserProxy(id);
}

InterfaceFunction creates a typed proxy that the core wires to an implementation at runtime. The wrapper functions forward calls to the proxies, giving consumers a straightforward function-based API without exposing the proxy mechanism.

Step 2 — Write the Implementation

The implementation file exports a namespace that matches the interface's internal namespace. Each function in the namespace contains the concrete logic that fulfills the corresponding proxy.

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

const users = new Map<string, User>();

export namespace internal {
  export async function getUserProxy(id: string): Promise<User> {
    const user = users.get(id);
    if (!user) throw new Error("User not found");
    return user;
  }

  export async function createUserProxy(name: string, email: string): Promise<User> {
    const user: User = { id: crypto.randomUUID(), name, email };
    users.set(user.id, user);
    return user;
  }

  export async function deleteUserProxy(id: string): Promise<void> {
    users.delete(id);
  }
}

The exported namespace and function names must match exactly. The core binds each proxy to its implementation by matching the namespace structure and function names between the interface and the implementation.

Step 3 — Declare the Interface in package.json

The implements field in package.json lists the npm package names from which consumers import the interface. For an embedded interface, the module lists its own package name because consumers import the interface from it.

package.json
{
  "name": "@your-org/my-module",
  "antelopeJs": {
    "implements": ["@your-org/my-module"]
  }
}

Consumers can then import the interface using the module's package name and the interface subpath:

import { GetUser, CreateUser } from "@your-org/my-module/interface";

Step 4 — Connect Interface and Implementation

The module entry point calls ImplementInterface to bind the interface proxies to their implementations. Place the call inside the construct lifecycle function so all bindings are ready before the start phase runs.

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

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

ImplementInterface takes two arguments: the interface module and the implementation module. The function matches the exported namespaces and wires each proxy to the corresponding concrete function.

You can call ImplementInterface multiple times to register implementations for different interfaces. Each call binds one interface to one implementation.

Adding Utility Code to Interfaces

Interface files can contain more than proxies and wrapper functions. Any implementation-agnostic code that consumers need belongs in the interface file alongside the contract definition.

Common additions include:

  • Decorator factories — Decorators that consumers use to annotate their classes or methods
  • Helper classes — Query builders, request formatters, metadata containers
  • Utility functions — Data transformation, validation helpers, constants

The following example adds an Authenticated decorator alongside the interface proxies. Consumers apply the decorator to inject authentication checks without depending on a specific auth implementation.

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

export interface UserPayload {
  id: string;
  roles: string[];
}

// Internal proxy
export namespace internal {
  export const validateProxy = InterfaceFunction<(token: string) => Promise<UserPayload>>();
}

// Public API
export function ValidateToken(token: string): Promise<UserPayload> {
  return internal.validateProxy(token);
}

// Decorator for consumers — generic code that belongs in the interface
export const Authenticated = MakeParameterDecorator<[]>((target, key, index) => {
  // Mark this parameter for token extraction and validation
});
Code in the interface file should be implementation-agnostic. If the logic depends on a specific implementation (database driver, HTTP library, etc.), the code belongs in the implementation file instead.

Extracting to a Standalone Package

An embedded interface works well when a single module owns both the contract and the implementation. When a second module needs to implement the same contract, extract the interface into its own npm package.

When to Extract

  • A second module needs to implement the same contract
  • Consumers should depend on the contract without depending on a specific implementation
  • The interface becomes part of a shared ecosystem

How to Extract

1. Create a new package for the interface (e.g., @your-org/interface-email). Move the interface file content to the new package's src/index.ts and publish it to npm.

2. Update the implementing module. Add the interface package as a dependency and set "implements" in the antelopeJs configuration:

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

3. Change the ImplementInterface call to import from the package instead of a local path:

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

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

4. Update consumers to import from the interface package instead of the module:

import { SendEmail } from "@your-org/interface-email";

await SendEmail({ to: "[email protected]", subject: "Hello", body: "World" });

Consumers now depend only on the interface package. Any module that implements @your-org/interface-email can fulfill the contract at runtime, and consumers remain unaffected by implementation swaps.

Start with an embedded interface inside your module. Extract to a standalone package only when a second implementation appears. Premature extraction adds complexity without benefit.

Best Practices

  • Keep interface files focused on the contract — types, proxies, wrapper functions, and shared utilities.
  • Place implementation-specific logic in the implementation file, not the interface.
  • Use InterfaceFunction for request-response patterns where a caller needs a result.
  • Use EventProxy for broadcasting events to multiple listeners.
  • Use RegisteringProxy for systems where modules contribute items to a collection.
  • Name wrapper functions clearly — they are the public API that consumers call.
  • Export shared types from the interface file so both implementations and consumers access the same definitions.

See also