Every non-trivial Dart or Flutter project I’ve built has eventually turned into a question of organization: how do I split my code into logical units, how do I reuse code across projects, and how do I pull in someone else’s well-tested code instead of reinventing it badly myself? The answer to all three is Dart’s library and package system, built around the pub tool and the pub.dev registry.
In this article I’ll go through how Dart libraries actually work under the hood, how to structure and publish your own packages, and how I manage dependencies day-to-day using pubspec.yaml and the pub CLI.
Table of Contents
- What Is a Library in Dart?
- Importing Libraries: Core, Package, and Relative Imports
- Controlling Visibility: Public vs Private
- Export Directives and Library Aggregation
- Creating Your Own Package
- Understanding pubspec.yaml
- Dependency Types: Regular, Dev, and Override
- Semantic Versioning and Version Constraints
- How Pub Resolves Dependencies Internally
- Publishing a Package to pub.dev
- Real-World Package Structuring for Flutter Apps
- Best Practices
- Common Mistakes and Debugging Tips
- FAQs
- Summary and References
1. What Is a Library in Dart?
In Dart, every file is implicitly its own library, even if I never write a library directive. This is different from languages where you must explicitly declare a module or namespace.
// lib/calculator.dart
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
That file, on its own, is already a complete library. I can optionally name it explicitly:
library calculator;
int add(int a, int b) => a + b;
int subtract(int a, int b) => a - b;
Naming a library explicitly is rarely necessary in modern Dart (the tooling identifies libraries by their URI), but I still see it in older codebases and it’s useful for documentation purposes with dartdoc.
2. Importing Libraries: Core, Package, and Relative Imports
There are three ways I import code in Dart, and each serves a different purpose.
Core library imports — Dart’s SDK ships built-in libraries that don’t require any package dependency:
import 'dart:core'; // implicitly imported everywhere, rarely written explicitly
import 'dart:math';
import 'dart:convert';
import 'dart:async';
void main() {
print(sqrt(16)); // Output: 4.0
print(jsonEncode({'a': 1})); // Output: {"a":1}
}
Package imports — for code from pub.dev or from another library within the same project:
import 'package:http/http.dart' as http;
import 'package:my_app/utils/calculator.dart';
void main() async {
final response = await http.get(Uri.parse('https://example.com'));
print(response.statusCode);
}
Relative imports — for files within the same package, especially convenient inside lib/:
import '../models/user.dart';
import './helpers.dart';
I personally prefer package: imports over relative imports for anything outside the immediate folder, because they don’t break if I move a file — the package root stays the anchor point regardless of directory depth.
Import Prefixes and Conflict Resolution
When two libraries export a symbol with the same name, I use as to disambiguate:
import 'package:vector_math/vector_math.dart' as vm;
import 'dart:math' as math;
void main() {
final piValue = math.pi;
final vector = vm.Vector2(1.0, 2.0);
print(piValue); // Output: 3.141592653589793
print(vector); // Output: [1.0,2.0]
}
I can also selectively import or hide symbols:
import 'package:collection/collection.dart' show ListEquality;
import 'package:collection/collection.dart' hide DeepCollectionEquality;
show limits the import to specific names; hide excludes specific names while importing everything else. I use show most often in shared libraries to keep the imported namespace clean and intentional.
3. Controlling Visibility: Public vs Private
Dart doesn’t have public, private, or protected keywords. Instead, visibility is controlled entirely by naming convention and library boundaries: any identifier prefixed with an underscore _ is private to the library (file) it’s declared in.
// lib/account.dart
class Account {
double _balance = 0; // private to this library
void deposit(double amount) {
_balance += amount;
}
double get balance => _balance;
}
// main.dart
import 'package:my_app/account.dart';
void main() {
final account = Account();
account.deposit(100);
print(account.balance); // Output: 100.0
// print(account._balance); // Error: _balance isn't defined
}
Note the subtlety: privacy is per-file, not per-class. Two classes in the same file can both access each other’s _ members freely, but a class in a different file cannot, even within the same package.
// lib/bank.dart
class _InternalLedger {
int _entries = 0;
void record() => _entries++;
}
class Bank {
final _ledger = _InternalLedger();
void deposit() => _ledger.record(); // fine, same library
}
4. Export Directives and Library Aggregation
When building a package, I often want consumers to import one single file instead of five separate ones. That’s what export is for.
// lib/my_package.dart
export 'src/models/user.dart';
export 'src/models/product.dart';
export 'src/services/api_client.dart';
Now consumers only need:
import 'package:my_package/my_package.dart';
and they get User, Product, and ApiClient all at once, without needing to know my internal file structure. This is the standard pattern for every well-designed pub.dev package — a single “barrel file” at lib/<package_name>.dart that re-exports the public API, while implementation details live under lib/src/ (which, by convention, consumers should never import directly).
I can also combine export with show/hide to curate exactly what’s public:
export 'src/internal_utils.dart' show formatCurrency;
5. Creating Your Own Package
Creating a new Dart package is a single command:
dart create -t package my_utils
This scaffolds:
my_utils/
├── lib/
│ ├── my_utils.dart
│ └── src/
│ └── my_utils_base.dart
├── test/
│ └── my_utils_test.dart
├── pubspec.yaml
├── CHANGELOG.md
├── README.md
└── LICENSE
For a Flutter-specific package (one that depends on the Flutter SDK), I use:
flutter create --template=package my_flutter_widgets
Or for a package that includes platform-specific native code (a plugin):
flutter create --template=plugin --platforms=android,ios my_native_plugin
6. Understanding pubspec.yaml
pubspec.yaml is the manifest file at the root of every Dart/Flutter project — it defines the package’s identity, its dependencies, and metadata.
name: my_utils
description: A collection of common utility functions for Dart projects.
version: 1.0.0
homepage: https://github.com/awjunaid/my_utils
repository: https://github.com/awjunaid/my_utils
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
http: ^1.2.0
collection: ^1.18.0
dev_dependencies:
test: ^1.25.0
lints: ^4.0.0
Key fields:
- name — must match
[a-z0-9_]+, no dashes, no capital letters. - version — follows semantic versioning (see section 8).
- environment.sdk — the Dart SDK version constraint your package supports.
- dependencies — packages your published code needs at runtime.
- dev_dependencies — packages only needed for development (testing, linting, build tools), never bundled with a published release’s runtime footprint for consumers.
7. Dependency Types: Regular, Dev, and Override
Regular dependencies are declared under dependencies: and are required wherever your package is used.
Dev dependencies, under dev_dependencies:, are only needed inside this repository — packages like test, mockito, build_runner, or lints.
Dependency overrides let me forcibly pin a version, usually to resolve a conflict or test against an unreleased version:
dependency_overrides:
http: ^1.3.0
I use dependency_overrides sparingly and almost always temporarily — it’s a signal that something in the dependency graph needs attention, not a permanent fix. I’ve also used path-based overrides during local development of a package that another one of my projects depends on:
dependency_overrides:
my_shared_models:
path: ../my_shared_models
This lets me test changes to my_shared_models in a consuming app before publishing a new version.
8. Semantic Versioning and Version Constraints
Dart packages follow semantic versioning: MAJOR.MINOR.PATCH.
- MAJOR — breaking API changes.
- MINOR — new backward-compatible functionality.
- PATCH — backward-compatible bug fixes.
Version constraint syntax in pubspec.yaml:
dependencies:
http: ^1.2.0 # >=1.2.0 <2.0.0 (caret syntax, most common)
path: '>=1.8.0 <2.0.0' # explicit range
meta: any # any version (rarely recommended)
The caret (^) syntax is what I use by default — it means “compatible with,” allowing minor and patch updates but blocking major version bumps that could introduce breaking changes.
9. How Pub Resolves Dependencies Internally
When I run dart pub get or flutter pub get, pub performs constraint solving across the entire dependency graph — not just my direct dependencies, but every dependency-of-a-dependency, recursively.
dart pub get
Output (abbreviated):
Resolving dependencies...
+ http 1.2.2
+ collection 1.18.0
+ async 2.11.0
+ meta 1.15.0
Downloading http 1.2.2...
Downloading collection 1.18.0...
Got dependencies!
Pub uses a version solving algorithm (a SAT-like constraint solver, referred to as “pubgrub” internally, similar in spirit to the algorithm used in some other language ecosystems) to find a single set of package versions that satisfies every constraint in the graph simultaneously. If it can’t find a compatible set, it reports a clear conflict:
Because my_app depends on package_a >=2.0.0 which depends on
shared_lib ^3.0.0, and my_app depends on shared_lib ^2.0.0,
version solving failed.
Two files matter here:
- pubspec.yaml — the constraints I declare.
- pubspec.lock — the exact resolved versions pub picked, generated after
pub get, and checked into version control for applications (though typically not committed for packages, since packages should be tested against a range of dependency versions, not one pinned set).
dart pub upgrade # upgrade within existing constraints
dart pub upgrade --major-versions # upgrade past major version bumps, updating pubspec.yaml
dart pub outdated # see what's out of date
dart pub outdated is one I run regularly — it shows current, upgradable, and resolvable versions side by side, and flags packages that are discontinued or have breaking changes pending.
10. Publishing a Package to pub.dev
Once my package is ready, I publish it with:
dart pub publish --dry-run # validate first, catches common mistakes
dart pub publish # actually publish
The dry run checks for things like:
- Missing or invalid
pubspec.yamlfields. - Files that shouldn’t be published (checked against
.pubignoreor.gitignore). - A missing
CHANGELOG.mdentry for the current version. - License file presence.
pub.dev then runs static analysis on the package and produces a pub score based on:
- Conventions (valid pubspec, example directory, etc.)
- Documentation (README quality, dartdoc coverage)
- Platform support (declares which platforms it supports: Android, iOS, web, etc.)
- Analysis (no static analysis errors/warnings)
- Dependencies (up-to-date, no discontinued packages)
I always run dart doc locally before publishing to make sure my public API documentation renders cleanly, since this feeds directly into the pub score and into what other developers see on the package’s pub.dev page.
11. Real-World Package Structuring for Flutter Apps
For any Flutter app beyond a prototype, I structure internal code as if it were multiple packages — even without literally publishing them — using a monorepo layout with melos or Flutter’s built-in workspace support:
my_flutter_app/
├── packages/
│ ├── core_models/
│ ├── core_network/
│ ├── feature_auth/
│ └── feature_profile/
└── apps/
└── my_flutter_app/
Each packages/* folder is its own Dart/Flutter package with its own pubspec.yaml, imported by the main app via a path dependency:
dependencies:
core_models:
path: ../packages/core_models
core_network:
path: ../packages/core_network
This gives me clean separation of concerns, faster incremental builds (since unrelated packages don’t need reanalysis), and the option to publish any of them independently later if they turn out to be broadly useful.
12. Best Practices
- Use barrel files (
export) to expose a clean public API and hidesrc/implementation details. - Pin dev tool versions tightly, but keep runtime dependency constraints as wide as reasonably possible to avoid unnecessary conflicts for your consumers.
- Commit
pubspec.lockfor applications, but not for packages you intend to publish. - Run
dart pub outdatedregularly, not just when something breaks. - Prefer
package:imports over deep relative imports (../../../../models/user.dart) for readability and refactor safety. - Document your public API with
///doc comments — they directly power both IDE tooltips and generated documentation.
13. Common Mistakes and Debugging Tips
Mistake 1 — Importing src/ files directly from another package:
import 'package:some_package/src/internal_helper.dart'; // fragile, unsupported
This works but bypasses the package’s intended public API and can break silently on any patch release, since src/ isn’t considered part of the semantic versioning contract. Always import the top-level barrel file instead.
Mistake 2 — Overusing any version constraints:
dependencies:
some_package: any
This tells pub “accept literally any version,” which defeats the purpose of dependency management and can silently pull in a breaking major version. Always specify at least a lower bound.
Mistake 3 — Circular dependencies between local packages: If package_a depends on package_b and package_b depends back on package_a, pub get will fail to resolve. The fix is almost always to extract the shared code both packages need into a third, lower-level package that both depend on.
Debugging tip: when pub get fails with a version solving error, run dart pub deps to visualize the entire dependency tree — it’s much easier to spot the actual conflicting constraint chain than reading pub’s text output alone.
dart pub deps
14. FAQs
Q: What’s the difference between a Dart package and a Flutter package? A Dart package works in any Dart environment (server, CLI, web). A Flutter package additionally depends on the Flutter SDK and can include widgets — it can only be used in Flutter projects.
Q: Can one file belong to multiple libraries? No — every Dart file is exactly one library. But one library (via export) can re-expose symbols from many other files, effectively acting as an aggregation point.
Q: Do I need to explicitly write library my_lib; at the top of my files? No, this is now optional and rarely needed in modern Dart, except when attaching library-level documentation comments or using the part/part of directives (used for legacy code generation).
Q: What happens if two dependencies need conflicting versions of a third package? Pub’s version solver tries to find a version of the shared dependency that satisfies both constraints. If it’s genuinely impossible, pub get fails with a clear conflict error, and you’ll need to update one of the direct dependencies or use dependency_overrides as a temporary workaround.
Q: Should I commit pubspec.lock? Yes for applications (ensures reproducible builds across machines and CI). No for packages you publish (so pub get can resolve against the latest compatible versions when your package is used elsewhere).
15. Summary
Dart’s library system is refreshingly simple at its core — every file is a library, visibility is controlled by an underscore convention, and export lets you compose a clean public API from many internal files. Layered on top of that, pub and pubspec.yaml give you a dependency management system with real semantic versioning discipline and a constraint solver that keeps large dependency graphs consistent. Whether I’m publishing a reusable package to pub.dev or just organizing a large Flutter app into internal packages, these same primitives — libraries, exports, and pub’s dependency resolution — are what keep the codebase maintainable as it grows.
