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| Aspect | Shelf | Conduit |
|---|---|---|
| ORM | ✗ (manual / other packages) | ✓ built-in |
| Auth | ✗ (manual) | ✓ built-in OAuth2 |
| DB migration | ✗ | ✓ built-in |
| Code gen | ✗ | ✓ conduit CLI |
| Boilerplate | Little | More |
| Flexibility | Very high | Limited to Conduit’s patterns |
| Learning curve | Low | Moderate-high |
| Best for | Lightweight APIs, microservices | Complex 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.
ApplicationChannelis the configuration hub — database initialization, auth server, and routing all happen here, in theprepare()andentryPointmethods.ResourceControllerwith@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.ManagedObjectis the ORM foundation — define models as two classes:class Model extends ManagedObject<_Model>andclass _Modelcontaining annotated properties.- Automatic migrations via
conduit db generate— Conduit detects model changes and generates migration SQL.conduit db upgradeexecutes them on the database.- Built-in OAuth2 —
AuthServer,AuthController, andAuthorizer.bearer()provide OAuth2 authentication with a token endpoint, refresh tokens, and scopes without extra configuration.Agentfor 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.