Conduit #

Conduit is the most complete Dart web server framework — providing an integrated ORM, OAuth2 authentication, database migrations, and code generation in a single package. Conduit is the successor to Aqueduct (a popular framework that’s no longer actively developed) with a more modern design and active maintenance. If you’re building a REST API that needs a database, authentication, and enterprise features without assembling all the components manually, Conduit is the most complete choice in the Dart server-side ecosystem.

Conduit vs Shelf vs Aqueduct #

flowchart LR
    subgraph "Low-level"
        S["Shelf\n• Composable middleware\n• No ORM\n• Maximum flexibility"]
    end
    subgraph "Full-featured"
        C["Conduit\n• Integrated ORM\n• Built-in OAuth2\n• Database migrations\n• Code generation"]
    end
    subgraph "Deprecated"
        A["Aqueduct\n• Conduit's predecessor\n• No longer actively developed"]
    end
    A -->|fork & rewrite| C
AspectShelfConduit
ORM✗ (manual / other packages)✓ built-in
Auth✗ (manual)✓ built-in OAuth2
DB migration✓ built-in
Code genconduit CLI
BoilerplateLittleMore
FlexibilityVery highLimited to Conduit’s patterns
Learning curveLowModerate-high
Best forLightweight APIs, microservicesComplex APIs with DB and auth

Installation #

# Install the Conduit CLI globally
dart pub global activate conduit

# Create a new project
conduit create nama_project

# Or with a PostgreSQL database from the start
conduit create --template db nama_project

# Run the development server
conduit serve

# The created project structure:
nama_project/
  ├── lib/
  │   ├── nama_project.dart        ← entry point
  │   ├── channel.dart             ← request pipeline
  │   └── controller/
  │       └── identitas_controller.dart
  ├── migrations/                  ← database migrations
  ├── test/
  ├── config.yaml                  ← configuration
  ├── config.src.yaml              ← configuration template
  └── pubspec.yaml

Channel — the Application Entry Point #

ApplicationChannel is the main class that configures the entire request pipeline — routing, middleware, and database connections:

// lib/channel.dart
import 'package:conduit_core/conduit_core.dart';
import 'controllers/produk_controller.dart';
import 'controllers/auth_controller.dart';

class TokoChannel extends ApplicationChannel {
  // Database context — initialized once, used by all controllers
  late ManagedContext context;
  late AuthServer authServer;

  @override
  Future prepare() async {
    // Logger configuration
    logger.onRecord.listen((rec) => print('$rec ${rec.error ?? ""}'));

    // Read configuration from config.yaml
    final config = TokoConfig(options!.configurationFilePath!);

    // Initialize the database
    final dataModel = ManagedDataModel.fromCurrentMirrorSystem();
    final psc = PostgreSQLPersistentStore(
      config.database.username,
      config.database.password,
      config.database.host,
      config.database.port,
      config.database.databaseName,
    );
    context = ManagedContext(dataModel, psc);

    // Set up the Auth server for OAuth2
    final authStorage = ManagedAuthDelegate<Pengguna>(context);
    authServer = AuthServer(authStorage);
  }

  @override
  Controller get entryPoint {
    final router = Router();

    // Public routes
    router
      .route('/produk/[:id]')
      .link(() => ProdukController(context));

    router
      .route('/auth/token')
      .link(() => AuthController(authServer));

    // Routes requiring authentication
    router
      .route('/profil')
      .link(() => Authorizer.bearer(authServer))
      ..link(() => ProfilController(context));

    router
      .route('/admin/[:path]')
      .link(() => Authorizer.bearer(authServer, scopes: ['admin']))
      ..link(() => AdminController(context));

    return router;
  }
}

// Configuration from YAML
class TokoConfig extends Configuration {
  late DatabaseConfiguration database;
  TokoConfig(String path) : super.fromFile(File(path));
}

Controllers — Request Handlers #

ResourceController provides a declarative way to define handlers per HTTP method using annotations:

// lib/controllers/produk_controller.dart
import 'package:conduit_core/conduit_core.dart';
import '../model/produk.dart';

class ProdukController extends ResourceController {
  ProdukController(this.context);
  final ManagedContext context;

  // GET /produk — fetch all with pagination
  @Operation.get()
  Future<Response> ambilSemua({
    @Bind.query('halaman') int halaman = 1,
    @Bind.query('perHalaman') int perHalaman = 20,
    @Bind.query('kategori') String? kategori,
  }) async {
    final query = Query<Produk>(context)
      ..where((p) => p.aktif).equalTo(true)
      ..fetchLimit = perHalaman
      ..fetchOffset = (halaman - 1) * perHalaman
      ..sortBy((p) => p.dibuatPada, QuerySortOrder.descending);

    if (kategori != null) {
      query.where((p) => p.kategori).equalTo(kategori);
    }

    final produkList = await query.fetch();
    final total = await query.reduce.count();

    return Response.ok({
      'data': produkList.map((p) => p.asMap()).toList(),
      'total': total,
      'halaman': halaman,
      'perHalaman': perHalaman,
    });
  }

  // GET /produk/:id — fetch one
  @Operation.get('id')
  Future<Response> ambilSatu(@Bind.path('id') int id) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..where((p) => p.aktif).equalTo(true);

    final produk = await query.fetchOne();
    if (produk == null) {
      return Response.notFound(body: {'error': 'Product not found'});
    }

    return Response.ok(produk.asMap());
  }

  // POST /produk — create a new product
  @Operation.post()
  Future<Response> buat(@Bind.body() Produk produkBaru) async {
    produkBaru
      ..aktif = true
      ..dibuatPada = DateTime.now().toUtc();

    final query = Query<Produk>(context)..values = produkBaru;
    final hasil = await query.insert();

    return Response.ok(hasil.asMap())..statusCode = 201;
  }

  // PUT /produk/:id — update a product
  @Operation.put('id')
  Future<Response> perbarui(
    @Bind.path('id') int id,
    @Bind.body() Produk produkUpdate,
  ) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..values = (produkUpdate..diubahPada = DateTime.now().toUtc());

    final hasil = await query.updateOne();
    if (hasil == null) {
      return Response.notFound(body: {'error': 'Product not found'});
    }

    return Response.ok(hasil.asMap());
  }

  // DELETE /produk/:id — soft delete
  @Operation.delete('id')
  Future<Response> hapus(@Bind.path('id') int id) async {
    final query = Query<Produk>(context)
      ..where((p) => p.id).equalTo(id)
      ..values.aktif = false;

    final hasil = await query.updateOne();
    if (hasil == null) {
      return Response.notFound(body: {'error': 'Product not found'});
    }

    return Response.ok({'pesan': 'Product deleted successfully'});
  }
}

ORM — ManagedObject #

Conduit has an integrated ORM based on ManagedObject. Each model maps to a database table:

// lib/model/produk.dart
import 'package:conduit_core/conduit_core.dart';

class Produk extends ManagedObject<_Produk> implements _Produk {}

class _Produk {
  @primaryKey
  int? id;

  @Column(indexed: true)
  String? nama;

  @Column()
  String? deskripsi;

  @Column()
  double? harga;

  @Column(defaultValue: '0')
  int? stok;

  @Column(indexed: true)
  String? kategori;

  @Column(defaultValue: 'true')
  bool? aktif;

  @Column()
  DateTime? dibuatPada;

  @Column(nullable: true)
  DateTime? diubahPada;

  // Relationship — one product belongs to one category
  @Relate(#produk, isRequired: true, onDelete: DeleteRule.cascade)
  KategoriProduk? kategoriObj;
}

// Relationship model
class KategoriProduk extends ManagedObject<_KategoriProduk>
    implements _KategoriProduk {}

class _KategoriProduk {
  @primaryKey
  int? id;

  @Column(unique: true)
  String? nama;

  // Reverse relationship — one category has many products
  ManagedSet<Produk>? produk;
}

The Query DSL #

Conduit provides a type-safe query builder:

// Fetch with filters
final mahal = await Query<Produk>(context)
  ..where((p) => p.harga).greaterThan(5000000)
  ..where((p) => p.aktif).equalTo(true)
  ..sortBy((p) => p.harga, QuerySortOrder.ascending)
  ..fetchLimit = 10
  .fetch();

// JOIN — include relationships
final denganKategori = await Query<Produk>(context)
  ..join(object: (p) => p.kategoriObj)
  ..where((p) => p.aktif).equalTo(true)
  .fetch();

// Aggregations
final totalHarga = await Query<Produk>(context)
  .reduce
  .sum((p) => p.harga!);

final jumlah = await Query<Produk>(context)
  ..where((p) => p.aktif).equalTo(true)
  .reduce
  .count();

// Insert
final baru = await (Query<Produk>(context)
  ..values.nama = 'Laptop'
  ..values.harga = 15000000
  ..values.aktif = true
  ..values.dibuatPada = DateTime.now().toUtc()
).insert();

// Update
await (Query<Produk>(context)
  ..where((p) => p.id).equalTo(42)
  ..values.harga = 14000000
  ..values.diubahPada = DateTime.now().toUtc()
).updateOne();

// Delete
await (Query<Produk>(context)
  ..where((p) => p.aktif).equalTo(false)
).delete();

Database Migrations #

Conduit manages the database schema through automatically generated migration files:

# Generate a migration from model changes
conduit db generate

# This produces a file in migrations/:
# migrations/00000001_Initial.migration.dart

# Preview the SQL that will be executed
conduit db upgrade --dry-run

# Run the migration on the database
conduit db upgrade

# Roll back to a specific version
conduit db upgrade --target 1

# See the migration status
conduit db list-versions
// migrations/00000001_Initial.migration.dart — generated automatically by Conduit
import 'package:conduit_core/conduit_core.dart';

class Initial extends Migration {
  @override
  Future upgrade() async {
    // Create tables
    database.createTable(SchemaTable('_kategori_produk', [
      SchemaColumn('id', ManagedPropertyType.bigInteger,
          isPrimaryKey: true, autoincrement: true),
      SchemaColumn('nama', ManagedPropertyType.string,
          isUnique: true, isIndexed: true),
    ]));

    database.createTable(SchemaTable('_produk', [
      SchemaColumn('id', ManagedPropertyType.bigInteger,
          isPrimaryKey: true, autoincrement: true),
      SchemaColumn('nama', ManagedPropertyType.string, isIndexed: true),
      SchemaColumn('harga', ManagedPropertyType.doublePrecision),
      SchemaColumn('stok', ManagedPropertyType.integer, defaultValue: '0'),
      SchemaColumn('aktif', ManagedPropertyType.boolean, defaultValue: 'true'),
      SchemaColumn('dibuat_pada', ManagedPropertyType.datetime),
      SchemaColumn.relationship('kategori_obj_id', ManagedPropertyType.bigInteger,
          relatedTableName: '_kategori_produk', rule: DeleteRule.cascade),
    ]));
  }

  @override
  Future downgrade() async {
    database.deleteTable('_produk');
    database.deleteTable('_kategori_produk');
  }

  @override
  Future seed() async {
    // Seed initial data
    await database.store.execute(
      "INSERT INTO _kategori_produk (nama) VALUES ('elektronik'), ('fashion')",
    );
  }
}

OAuth2 Authentication #

Conduit has a built-in OAuth2 server — complete with a token endpoint, refresh tokens, and scopes:

// lib/model/pengguna.dart
import 'package:conduit_core/conduit_core.dart';

// ManagedAuthResourceOwner makes this model an OAuth2 resource owner
class Pengguna extends ManagedObject<_Pengguna>
    implements _Pengguna, ManagedAuthResourceOwner<_Pengguna> {}

class _Pengguna extends ResourceOwnerTableDefinition {
  @Column(unique: true, indexed: true)
  String? email;

  String? nama;

  // hashedPassword and salt are inherited from ResourceOwnerTableDefinition
}

// lib/controllers/register_controller.dart
class RegisterController extends ResourceController {
  RegisterController(this.context, this.authServer);
  final ManagedContext context;
  final AuthServer authServer;

  @Operation.post()
  Future<Response> daftar(@Bind.body() Pengguna penggunaBaru) async {
    // Hash the password before saving
    final salt = generateRandomSalt();
    final hashedPassword = hashPassword(penggunaBaru.password!, salt);

    final query = Query<Pengguna>(context)
      ..values.email = penggunaBaru.email
      ..values.nama = penggunaBaru.nama
      ..values.hashedPassword = hashedPassword
      ..values.salt = salt;

    try {
      final pengguna = await query.insert();
      return Response.ok(pengguna.asMap()..remove('hashedPassword')..remove('salt'));
    } on QueryException catch (e) {
      if (e.event == QueryExceptionEvent.conflict) {
        return Response.conflict(body: {'error': 'Email already registered'});
      }
      rethrow;
    }
  }
}

// OAuth2 configuration in channel.dart
// POST /auth/token with body: grant_type=password&username=&password=
// Response: { access_token, token_type, expires_in, refresh_token }
router
  .route('/auth/token')
  .link(() => AuthController(authServer));

// Endpoints requiring auth
router
  .route('/api/[:path]')
  .link(() => Authorizer.bearer(authServer))  // verify the Bearer token
  ..link(() => ApiController(context));

Middleware — Filters #

Conduit uses the Controller concept, which can be chained as middleware:

// Logging middleware
class LoggingMiddleware extends Controller {
  @override
  FutureOr<RequestOrResponse> handle(Request request) async {
    final mulai = DateTime.now();
    print('[${mulai.toIso8601String()}] ${request.raw.method} ${request.raw.uri.path}');

    // Forward to the next controller
    final response = await super.handle(request);

    final durasi = DateTime.now().difference(mulai);
    print('→ ${(response as Response).statusCode} (${durasi.inMilliseconds}ms)');

    return response;
  }
}

// CORS middleware
class CORSController extends Controller {
  @override
  FutureOr<RequestOrResponse> handle(Request request) async {
    if (request.raw.method == 'OPTIONS') {
      return Response.ok(null)
        ..contentType = null
        ..headers['Access-Control-Allow-Origin'] = '*'
        ..headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
        ..headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization';
    }

    final response = await super.handle(request) as Response;
    return response
      ..headers['Access-Control-Allow-Origin'] = '*';
  }
}

// Usage in channel.dart
@override
Controller get entryPoint {
  final router = Router();

  // Add CORS and logging middleware to all routes
  router
    .route('/api/[:path]')
    .link(() => CORSController())
    ..link(() => LoggingMiddleware())
    ..link(() => ApiController(context));

  return router;
}

Testing in Conduit #

Conduit provides Agent for integration testing without needing an external server:

// test/produk_test.dart
import 'package:conduit_test/conduit_test.dart';
import 'package:test/test.dart';
import 'package:nama_project/channel.dart';

void main() {
  final app = Application<TokoChannel>();
  late Agent agent;

  setUpAll(() async {
    // Run the app in test mode
    await app.startOnCurrentIsolate();
    agent = Agent(app);
  });

  tearDownAll(() async {
    await app.stop();
  });

  group('GET /produk', () {
    test('returns a list of products', () async {
      final response = await agent.get('/produk');
      expect(response, hasStatus(200));
      expect(response, hasBody(partial({
        'data': isList,
        'total': isInteger,
      })));
    });

    test('filters by category', () async {
      final response = await agent.get('/produk?kategori=elektronik');
      expect(response, hasStatus(200));
    });
  });

  group('POST /produk', () {
    test('creates a new product successfully', () async {
      final response = await agent.post('/produk', body: {
        'nama': 'Test Produk',
        'harga': 100000,
        'stok': 10,
      });
      expect(response, hasStatus(201));
      expect(response, hasBody(partial({'id': isInteger})));
    });

    test('fails without authentication on protected endpoints', () async {
      final response = await agent.get('/admin/produk');
      expect(response, hasStatus(401));
    });
  });
}

Configuration #

# config.yaml
database:
  host: localhost
  port: 5432
  databaseName: toko_db
  username: postgres
  password: password

# config.production.yaml — overrides for production
database:
  host: prod-db.example.com
  databaseName: toko_production
  username: app_user
  password: ${DB_PASSWORD}  # read from an environment variable
# Run with a different config
conduit serve --config-path config.production.yaml

# Or via an environment variable
DB_PASSWORD=secret conduit serve

Summary #

  • Conduit is the most complete Dart server-side framework — ORM, OAuth2, database migrations, and code generation in one package. Use it when building complex APIs with databases.
  • ApplicationChannel is the configuration hub — database initialization, auth server, and routing all happen here, in the prepare() and entryPoint methods.
  • ResourceController with @Operation.get(), @Operation.post(), @Operation.put(), @Operation.delete() annotations — declarative routing that automatically maps HTTP methods to handlers.
  • @Bind.path(), @Bind.query(), @Bind.body() — type-safe parameter binding from URL paths, query strings, and request bodies.
  • ManagedObject is the ORM foundation — define models as two classes: class Model extends ManagedObject<_Model> and class _Model containing annotated properties.
  • Automatic migrations via conduit db generate — Conduit detects model changes and generates migration SQL. conduit db upgrade executes them on the database.
  • Built-in OAuth2AuthServer, AuthController, and Authorizer.bearer() provide OAuth2 authentication with a token endpoint, refresh tokens, and scopes without extra configuration.
  • Agent for testing — integration tests without an external server, directly invoking handlers and verifying responses with Conduit-specific matchers.
  • Choose Conduit if: you need integrated ORM, auth, and migrations. Choose Shelf if: you need maximum flexibility with minimal overhead.
  • Conduit is the active successor to Aqueduct — if you find Aqueduct code or tutorials, migrating to Conduit is relatively easy because the APIs are very similar.

← Previous: Flutter   Next: Aqueduct →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact