apiAir

modular_api

The api layer of MACSS

Build modular, contract-first APIs — server side

modular_api implements the api element of MACSS. It is simultaneously a methodology (contract-first, use-case driven), a specification (conventions that define how modules, DTOs, repositories and use cases relate), and a set of SDKs in three languages that produce structurally identical openapi.json outputs from the same conceptual model.

Philosophy

Every operation is a UseCase — a unit with typed Input, typed Output, a validate() step and an execute() step. There are no controllers that mix validation, business logic and HTTP concerns. The framework enforces the separation.

CQRS is structural, not optional: commands travel over REST, queries over GraphQL. The OpenAPI spec and the GraphQL schema are generated automatically — they are a consequence of the use cases, not a document you maintain separately.

Every server ships four endpoints with zero configuration: /docs, /health, /openapi.json, /openapi.yaml. Metrics at /metrics are opt-in.

Quick start Dart · also available in TS and Python

// pubspec.yaml
dependencies:
  modular_api: ^1.0.0
// bin/server.dart
import 'package:modular_api/modular_api.dart';

Future<void> main() async {
  final api = ModularApi(
    basePath: '/api',
    title: 'My App',
    version: '1.0.0',
  );

  api.module('clients', (m) {
    m.usecase('create', CreateClient.fromJson);
    m.usecase('list',   ListClients.fromJson);
  });

  await api.serve(port: 8080);
}
# Commands → REST
curl -X POST http://localhost:8080/api/clients/create \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme"}'

# Queries → GraphQL (auto-generated)
# Docs → http://localhost:8080/docs
# Spec → http://localhost:8080/openapi.json

UseCase structure

Every use case follows the same lifecycle — regardless of language:

// Input: validated before execute() is called
class CreateClientInput extends Input {
  final String name;
  CreateClientInput({required this.name});

  @override
  String? validate() {
    if (name.isEmpty) return 'name is required';
    return null;
  }

  factory CreateClientInput.fromJson(Map<String, dynamic> j) =>
      CreateClientInput(name: j['name'] as String);
}

// Output: serialized as the HTTP response
class CreateClientOutput extends Output {
  final String id;
  final String name;
  CreateClientOutput({required this.id, required this.name});

  @override
  Map<String, dynamic> toJson() => {'id': id, 'name': name};
}

// UseCase: pure business logic
class CreateClient implements UseCase<CreateClientInput, CreateClientOutput> {
  @override
  Future<CreateClientOutput> execute(CreateClientInput input) async {
    final id = await repository.insert(input.name);
    return CreateClientOutput(id: id, name: input.name);
  }
}