· 6 min read
Flutter Local Package: Split the Map Engine From the App
How I moved Mapika's map engine into a Flutter local package with one rule for the boundary and a stream of overlay objects as the only contract.
A map screen is the fastest way to grow a Flutter widget that nobody wants to touch. Rendering, hit testing, GeoJSON parsing, colors, user taps and business rules all end up in one place. In Mapika, my travel map app, I pulled the map engine out into a Flutter local package called mapika_map. This post is about the one rule that keeps that package clean, and the small contract the app and the package use to talk.
Why a Flutter local package, not just a folder
You can get some separation with folders inside lib/. The problem is that folders do not stop imports. Any file can import any other file, and on a busy day it will.
A local package is a real boundary. It has its own pubspec.yaml, its own dependencies and its own tests. The app depends on the package, and the package cannot import anything from the app, because it does not know the app exists. The compiler enforces the rule for you.
You do not need to publish it. A path dependency in the app's pubspec.yaml is enough:
dependencies:
mapika_map:
path: packages/mapika_map
The Dart docs on path dependencies cover the details. Changes in the package show up in the app on the next hot reload or build, so day-to-day work feels the same as before.
The rule: the package draws, the app decides
This is the whole architecture in one line:
- The package owns rendering and map data. It loads the Natural Earth GeoJSON, projects shapes, draws them, handles zoom and pan, and finds which shape is under a tap.
- The app owns state and business rules. It knows which places a user visited, lived in or wants to visit, what a premium user can see, and what happens after a tap. That logic lives in Cubits, as in the rest of Mapika.
The test I use for any new piece of code: does it need to know what "visited" means? If yes, it belongs in the app. If it only needs to know "fill this shape with this color", it belongs in the package.
That test sounds trivial, but it catches most mistakes. The tempting shortcut is to add a visitedCountries parameter to the map widget. It saves ten minutes today. Then the package knows a product concept, and every new status (lived in, want to visit, a new badge) changes the package API.
The contract: a stream of overlay objects
The app and the package talk through one thing: a stream of overlay objects. An overlay says what to draw on which shape. It does not say why.
Here is a simplified version of the idea (names and fields are shortened for the post):
// packages/mapika_map/lib/src/overlay.dart
sealed class MapOverlay {
const MapOverlay();
}
class RegionFill extends MapOverlay {
const RegionFill({required this.regionId, required this.color});
final String regionId;
final Color color;
}
class RegionOutline extends MapOverlay {
const RegionOutline({required this.regionId, required this.width});
final String regionId;
final double width;
}
class PointMarker extends MapOverlay {
const PointMarker({required this.position, required this.style});
final LatLng position;
final MarkerStyle style;
}
The map widget takes the stream and reports taps back with plain IDs:
MapView(
overlays: overlayStream, // Stream<List<MapOverlay>>
onRegionTap: (regionId) { ... }, // the app decides what a tap means
)
On the app side, a small mapper turns state into overlays. This is the only place that knows both worlds:
// app/lib/map/overlay_mapper.dart
List<MapOverlay> toOverlays(PlacesState state, MapTheme theme) => [
for (final place in state.places)
RegionFill(
regionId: place.regionId,
color: theme.colorFor(place.status), // visited, lived, want-to-visit
),
];
And the stream itself comes straight from the Cubit:
final overlayStream = placesCubit.stream
.map((state) => toOverlays(state, mapTheme));
The package never sees PlacesState. The Cubit never sees a canvas.
Why a stream and not a list
A plain List<MapOverlay> parameter would work too, and for a static map it is simpler. A stream fits better when state changes often. The user marks a country, the Cubit emits, the mapper produces a new list, and the map updates. Inside the package, you are free to compare the new overlays with the old ones and repaint only what changed. The app does not need to know how.
Why sealed classes
A sealed class means the package can switch over every overlay type and the compiler tells you when a case is missing. When I add a new overlay type, the package code that ignores it fails to compile. That is exactly the reminder I want.
What the split gave me
These are the practical wins of this kind of split:
- Faster work on the map. A package can have its own small
example/app. You test rendering and gestures with fake overlays, without logging in, loading user data or passing a paywall. - Simpler tests. Package tests check "given these overlays, these shapes are painted". App tests check "given this state, these overlays come out". Neither test needs the other side.
- New features land in the app. Shareable map cards, badges and flight replay animations are product features. Their rules belong in the app; the package only needs a new overlay type when there is a new kind of thing to draw.
- Safer refactors. I can rewrite the painting code inside the package as long as the overlay contract stays the same. The app does not notice.
It is the same idea as the ad service interface in Mapika: the app owns the contract, and the details sit behind it. The difference is scale. An ad service is one class. A map engine is a whole package.
Mistakes to avoid
A few things I would watch from day one:
- Do not export everything. Keep code in
lib/src/and export only the public API from one file, for examplelib/mapika_map.dart. Anything not exported is free to change. - Do not put state management in the package. If the package depends on
flutter_bloc, it starts to look like an app. Let it take streams and callbacks, and let the app decide how to produce them. - Do not pass app models in. No
User, noPlace, noSubscription. Only IDs, colors, styles and coordinates. - Keep dependency versions aligned. The app and the package resolve to one version of each shared dependency. If the package pins something tightly, the app inherits the problem. Use normal version ranges.
- Split when the boundary is clear, not before. A package for every screen adds overhead. The map engine earned it because it is big, self-contained and changes for different reasons than the product does.
The Flutter docs on developing packages are a good reference for the package layout itself.
Takeaways
- A Flutter local package is a boundary the compiler enforces; a folder is only a suggestion.
- Use one rule for placement: if code needs to know a product concept like "visited", it belongs in the app.
- Make the contract small and neutral: overlays describe what to draw, never why.
- A stream of sealed overlay classes fits state that changes often and makes missing cases a compile error.
- Split out a package only when the boundary is already clear in your head.