Skip to content

Build a CRUD API

You’ll build a /notes API with list (paginated), show, create, update and delete endpoints. Input is validated with forms, logic lives in a service, and errors use the standard envelope. Storage is an in-memory repository registered as a singleton, so you can swap in gisila_orm later without touching the controller.

lib/models/note.dart:

class Note {
Note({required this.id, required this.title, required this.body, required this.updatedAt});
final int id;
final String title;
final String body;
final DateTime updatedAt;
Note copyWith({String? title, String? body}) => Note(
id: id,
title: title ?? this.title,
body: body ?? this.body,
updatedAt: DateTime.now().toUtc(),
);
Map<String, Object?> toJson() =>
{'id': id, 'title': title, 'body': body, 'updatedAt': updatedAt};
}

toJson may return a DateTime: the framework encodes it as ISO 8601.

lib/data/note_repository.dart:

import '../models/note.dart';
class NoteRepository {
final Map<int, Note> _rows = {};
var _nextId = 1;
List<Note> list({required int limit, required int offset}) =>
_rows.values.skip(offset).take(limit).toList();
int count() => _rows.length;
Note? find(int id) => _rows[id];
Note insert(String title, String body) {
final note = Note(id: _nextId++, title: title, body: body, updatedAt: DateTime.now().toUtc());
return _rows[note.id] = note;
}
Note save(Note note) => _rows[note.id] = note;
bool remove(int id) => _rows.remove(id) != null;
}

lib/forms/note_forms.dart:

import 'package:gisila/gisila.dart';
class CreateNoteForm extends Form {
final title = StringField(name: 'title', required: true, minLength: 1, maxLength: 200);
final body = StringField(name: 'body', maxLength: 10000, trim: false);
@override
List<FormField<Object?>> collectFields() => [title, body];
}
class UpdateNoteForm extends Form {
final title = StringField(name: 'title', minLength: 1, maxLength: 200);
final body = StringField(name: 'body', maxLength: 10000, trim: false);
@override
List<FormField<Object?>> collectFields() => [title, body];
}

The update form has no required fields, so clients can send only what changes.

lib/services/note_service.dart:

import 'package:gisila/gisila.dart';
import '../data/note_repository.dart';
import '../forms/note_forms.dart';
import '../models/note.dart';
class NoteService extends Service {
NoteRepository get _repo => singleton<NoteRepository>();
Page<Note> list(PageParams page) => Page(
_repo.list(limit: page.limit, offset: page.offset),
params: page,
total: _repo.count(),
);
Note get(int id) => _repo.find(id) ?? (throw NotFound('Note $id not found'));
Note create(CreateNoteForm form) =>
_repo.insert(form.title.value!, form.body.value ?? '');
Note update(int id, UpdateNoteForm form) =>
_repo.save(get(id).copyWith(title: form.title.value, body: form.body.value));
void delete(int id) {
if (!_repo.remove(id)) throw NotFound('Note $id not found');
}
}

lib/controllers/notes_controller.dart:

import 'package:gisila/gisila.dart';
import '../forms/note_forms.dart';
import '../models/note.dart';
import '../services/note_service.dart';
part 'notes_controller.g.dart';
@Controller('/notes')
class NotesController {
@Get('/')
Future<Page<Note>> list(NoteService notes, {int? limit, int? offset}) async =>
notes.list(PageParams(limit: limit, offset: offset));
@Get('/{id}')
Future<Note> show(int id, NoteService notes) async => notes.get(id);
@Post('/')
Future<Note> create(CreateNoteForm form, NoteService notes) async => notes.create(form);
@Patch('/{id}')
Future<Note> update(int id, UpdateNoteForm form, NoteService notes) async =>
notes.update(id, form);
@Delete('/{id}')
Future<void> delete(int id, NoteService notes) async => notes.delete(id);
}

id is declared as int, so /notes/abc is a 400 before your code runs. limit and offset are nullable query parameters; PageParams applies the default (25) and the cap (100).

bin/server.dart:

import 'package:gisila/gisila.dart';
import 'package:todo_api/controllers/notes_controller.dart';
import 'package:todo_api/data/note_repository.dart';
import 'package:todo_api/services/note_service.dart';
Future<void> main() async {
final app = GisilaApp(config: AppConfig(maxRequestBodyBytes: 256 * 1024));
app.registerSingleton<NoteRepository>(NoteRepository());
app.registerService<NoteService>(NoteService.new);
app.registerController(attacher: NotesController().attachToApp);
await app.start(port: 8080, stopOnSignals: true);
}
Terminal window
dart run build_runner build --delete-conflicting-outputs
dart run bin/server.dart
Terminal window
curl -s -X POST localhost:8080/notes -H 'content-type: application/json' \
-d '{"title":"First","body":"Hello"}'
# 201 {"id":1,"title":"First","body":"Hello","updatedAt":"2026-10-31T09:00:00.000Z"}
curl -s 'localhost:8080/notes?limit=10'
# {"items":[{"id":1,...}],"page":{"limit":10,"offset":0,"total":1,"has_more":false}}
curl -s -X PATCH localhost:8080/notes/1 -H 'content-type: application/json' -d '{"title":"Renamed"}'
# 200 {"id":1,"title":"Renamed",...}
curl -s -X POST localhost:8080/notes -H 'content-type: application/json' -d '{}'
# 400 {"error":{"status":400,"code":"validation_failed","message":"Invalid form data","details":{"title":"This field is required"}}}
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/notes/1
# 204