Ten years of Symfony. Dozens of API integrations. Then I switched to Flutter — and suddenly, making a clean API call felt like a chore.
If you've ever used Saloon PHP, you know the feeling: one class per API, one class per endpoint, everything typed, testable, readable. No magic annotations. No generated files. Just clean OOP.
I went looking for the Dart equivalent. I didn't find it. So I built it.
The itch
In the PHP/Laravel world, Saloon gives you this kind of structure:
// One connector per API
class ForgeConnector extends Connector {
public function resolveBaseUrl(): string {
return 'https://forge.laravel.com/api/v1';
}
}
// One request per endpoint
class GetServersRequest extends Request {
protected Method $method = Method::GET;
public function resolveEndpoint(): string {
return '/servers';
}
}
It's boring. It's explicit. It's wonderful to maintain six months later when you've forgotten everything about that API.
When I moved to Flutter, I expected something similar to exist. What I found instead was either scattered http.get() calls everywhere, or solutions that demand code generation.
What Dart has today
The two main options for structured API clients in Dart are Retrofit and Chopper. Both work. Both are mature. And both require build_runner.
Here's a typical Retrofit setup:
@RestApi(baseUrl: 'https://api.example.com')
abstract class ApiClient {
factory ApiClient(Dio dio) = _ApiClient;
@GET('/users')
Future<List<User>> getUsers();
@POST('/users')
Future<User> createUser(@Body() CreateUserRequest request);
}
You annotate, you run dart run build_runner build, you wait, you get a _ApiClient class generated in a .g.dart file. It works.
But there's friction. The kind that compounds over time.
Every time you change a request, you re-run the generator. In CI, build_runner adds minutes. In large projects with multiple API clients, it adds many minutes. The generated files create noise in diffs. You can't just read the code — you have to read the code and the generated code and understand the annotation magic that connects them.
For some teams, that trade-off is fine. Code generation gives you compile-time safety and zero boilerplate for serialization. If your project already uses Freezed or json_serializable everywhere, adding Retrofit barely changes your workflow.
But I didn't want that workflow. I wanted the Saloon workflow.
What I wanted
The requirements were simple:
- One class per API (Connector) — holds base URL, default headers, auth.
- One class per endpoint (Request) — defines method, path, body, query params.
-
Zero code generation. No annotations, no
.g.dart, no build step. - Type-safe body handling via mixins, not inheritance spaghetti.
- Pluggable auth that I can swap at runtime (set the token after login).
- My error handling, not Dio's. I decide when to throw.
That last point matters more than it seems. Dio throws DioException.badResponse for any 4xx/5xx by default. If you want to handle a 422 validation error differently from a 401, you're fighting the library instead of working with it.
Lucky Dart
Lucky Dart is what came out of it. Named after Lucky Luke — the cowboy who shoots faster than his shadow.
Here's the same API integration, Lucky-style:
import 'package:lucky_dart/lucky_dart.dart';
// 1. One Connector for the entire API
class ForgeConnector extends Connector {
final String _token;
ForgeConnector({required String token}) : _token = token;
@override
String resolveBaseUrl() => 'https://forge.laravel.com/api/v1';
@override
Authenticator? get authenticator => TokenAuthenticator(_token);
}
// 2. One Request per endpoint
class GetServersRequest extends Request {
@override String get method => 'GET';
@override String resolveEndpoint() => '/servers';
}
class CreateServerRequest extends Request with HasJsonBody {
final String name;
final String region;
CreateServerRequest({required this.name, required this.region});
@override String get method => 'POST';
@override String resolveEndpoint() => '/servers';
@override
Map<String, dynamic> jsonBody() => {
'name': name,
'region': region,
};
}
// 3. Use it
final forge = ForgeConnector(token: myToken);
final servers = await forge.send(GetServersRequest());
print(servers.jsonList());
No annotations. No generated files. No build step. You write it, it compiles, it works.
The body mixin pattern
This is the part that maps most directly from Saloon. Instead of one giant base class that tries to handle every content type, Lucky uses mixins:
// JSON body — sets Content-Type: application/json automatically
class CreateUser extends Request with HasJsonBody { ... }
// Form body — sets Content-Type: application/x-www-form-urlencoded
class LoginRequest extends Request with HasFormBody { ... }
// Multipart — sets Content-Type: multipart/form-data
class UploadAvatar extends Request with HasMultipartBody { ... }
// Also: HasXmlBody, HasTextBody, HasStreamBody
Each mixin does exactly two things: overrides body() to return your data, and overrides buildOptions() to set the correct Content-Type. That's it. No deep inheritance chain, no abstract factories. You mix in what you need.
The HasMultipartBody mixin returns a Future<FormData>, so you can read files from disk before the request fires. The HasStreamBody mixin requires a contentLength getter for the Content-Length header. Each one is a single-purpose module that does its job without affecting the others.
Auth that works at runtime
This was a real pain point. In many Flutter apps, you don't have a token when the app starts. You get it after login. Saloon handles this elegantly, and Lucky copies the approach:
class ApiConnector extends Connector {
Authenticator? _auth;
// Public setter — set the token after login
set authenticatorOverride(Authenticator? auth) => _auth = auth;
@override
Authenticator? get authenticator => _auth;
@override
String resolveBaseUrl() => 'https://api.example.com';
}
// Login flow
final api = ApiConnector();
final login = await api.send(LoginRequest(email: email, password: password));
api.authenticatorOverride = TokenAuthenticator(login.json()['token']);
// All subsequent requests are authenticated
final profile = await api.send(GetProfileRequest());
authenticator is a getter, re-evaluated on every send() call. Swap it, and the next request picks it up. No need to rebuild the connector or reinitialize Dio.
You can also disable auth per request — essential for the login endpoint itself:
class LoginRequest extends Request with HasFormBody {
@override bool? get useAuth => false; // skip auth for this one
@override bool get logRequest => false; // don't log credentials
// ...
}
Why validateStatus matters
This is the most important design decision in Lucky, and the one that took the longest to get right.
By default, Dio throws a DioException.badResponse for any HTTP response with a status code >= 400. This means your catch block receives a generic Dio exception that you then have to unwrap to figure out what actually happened. Was it a 401? A 422 with validation errors? A 500? You're back to if/else chains on the status code.
Lucky configures Dio with validateStatus: (_) => true. This tells Dio: "let everything through. I'll handle it."
_dio = Dio(BaseOptions(
baseUrl: resolveBaseUrl(),
validateStatus: (_) => true, // Lucky handles errors, not Dio
));
Then, in Connector.send(), Lucky checks the status code and throws typed exceptions:
try {
final response = await connector.send(GetUserRequest(42));
print(response.json()['name']);
} on NotFoundException catch (e) {
// 404 — typed, specific
} on UnauthorizedException catch (e) {
// 401 — handle token refresh
} on ValidationException catch (e) {
// 422 — e.errors has the field-level details
e.errors?.forEach((field, messages) {
print('$field: $messages');
});
} on LuckyException catch (e) {
// Anything else — e.statusCode, e.response available
}
If you'd rather handle status codes manually, set throwOnError to false on the connector and check response.isSuccessful yourself.
The consequence: DioException.badResponse is never emitted. Only network failures and timeouts reach the catch (DioException) block. HTTP errors are Lucky's domain.
The endpoint pattern
For large APIs, Lucky supports grouping related requests into endpoint classes. This gives you a namespace-based API that reads like English:
class ApiConnector extends Connector {
// ...
late final users = UsersEndpoint(this);
late final posts = PostsEndpoint(this);
}
// Usage
final api = ApiConnector(token: myToken);
await api.users.list();
await api.users.get(42);
await api.posts.create(title: 'Hello', content: 'World', userId: 1);
It's just a thin wrapper that groups send() calls. No new concepts, no new abstractions.
Logging without opinions
Lucky doesn't ship a logger. No dependency on logger, talker, or any third-party logging package. Instead, you wire callbacks:
class MyConnector extends Connector {
@override bool get enableLogging => true;
@override
void Function({required String message, String? level, String? context})
get onLog => ({required message, level, context}) {
print('[$level] $message'); // or talker.log(), or Logger.d(), or whatever
};
}
Your app, your logger, your rules. Lucky's LoggingInterceptor formats the output and respects per-request opt-out (logRequest: false for sensitive requests like login).
What it's not
Lucky is not a replacement for Retrofit if you need:
-
Automatic JSON serialization/deserialization to model classes. Lucky doesn't generate model mappings for you — but it's not raw either. The
as<T>()helper maps responses into your models in a single expression:
// Single object
final user = response.as((r) => User.fromJson(r.json()));
// List of objects
final users = response.as(
(r) => r.jsonList().map((e) => User.fromJson(e as Map<String, dynamic>)).toList(),
);
If you want zero manual mapping — not even a one-liner — Retrofit + json_serializable is the better choice. But if one line per endpoint feels like a fair trade for dropping build_runner entirely, Lucky is enough.
- Code generation for dozens of endpoints. If your API has 200 endpoints and you'd rather annotate than write classes, Retrofit saves time. Lucky trades automation for readability — that's a conscious choice, not an oversight.
- GraphQL. Lucky is REST-focused by design.
Lucky is a good fit when you want to understand your API layer by reading it. When you want to add a new endpoint without running a build command. When you're coming from PHP, Ruby, or Python and the OOP-first approach feels natural.
The numbers
The package ships with 112 tests (89 unit + 13 integration using a real dart:io HTTP server). dart analyze passes clean. Single runtime dependency: dio ^5.4.0. Dart SDK >=3.0.0 <4.0.0, Flutter-compatible.
It's published on pub.dev and the source is on GitHub.
Try it
dependencies:
lucky_dart: ^1.0.0
import 'package:lucky_dart/lucky_dart.dart';
class MyApiConnector extends Connector {
@override
String resolveBaseUrl() => 'https://jsonplaceholder.typicode.com';
}
class GetPostsRequest extends Request {
@override String get method => 'GET';
@override String resolveEndpoint() => '/posts';
}
void main() async {
final api = MyApiConnector();
final posts = await api.send(GetPostsRequest());
print('Got ${posts.jsonList().length} posts');
}
If you've ever used Saloon PHP and wished it existed in Dart, this is it. If you've never used Saloon but you're tired of build_runner, give it a spin.
Feedback, issues, and PRs welcome on GitHub.
Built by OwlNext, a worker cooperative in Dijon, France. We build custom software and break into systems for a living (legally).
Top comments (0)