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.
Prerequisites
Section titled “Prerequisites”- A project set up as in Create a project.
1. The model
Section titled “1. The model”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.
2. A repository singleton
Section titled “2. A repository singleton”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;}3. Forms
Section titled “3. Forms”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.
4. The service
Section titled “4. The service”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'); }}5. The controller
Section titled “5. The controller”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).
6. Wire it up
Section titled “6. Wire it up”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);}dart run build_runner build --delete-conflicting-outputsdart run bin/server.dartVerify it works
Section titled “Verify it works”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# 204Next steps
Section titled “Next steps”- Protect the endpoints: JWT authentication and role-based permissions.
- Store notes in Postgres: Use with gisila_orm.
- Cover it with tests: Test controllers.