DartwayTeam strict clean-code rules for ALL Flutter/Dart work (DartWay projects): self-explanatory naming (min 2 words), single responsibility per file (length is a soft signal: >200 lines a nudge, >350 a warning โ split by responsibility, not by line count), never pass BuildContext or WidgetRef as params, no _buildXxx() widget-returning methods, no ref.invalidate, no GlobalKey tree lookups, no outer padding/margin inside a widget, no private widget classes in public feature files; plus SOLID, KISS, DRY, YAGNI, Law of Demeter, composition over inheritance, separation of concerns, fail-fast, tell-don't-ask, single source of truth, and tests for complex features / non-trivial bugfixes.
Installation
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fรผgen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prรผfen und installieren.
DartwayTeam strict clean-code rules for ALL Flutter/Dart work (DartWay projects): self-explanatory naming (min 2 words), single responsibility per file (length is a soft signal: >200 lines a nudge, >350 a warning โ split by responsibility, not by line count), never pass BuildContext or WidgetRef as params, no _buildXxx() widget-returning methods, no ref.invalidate, no GlobalKey tree lookups, no outer padding/margin inside a widget, no private widget classes in public feature files; plus SOLID, KISS, DRY, YAGNI, Law of Demeter, composition over inheritance, separation of concerns, fail-fast, tell-don't-ask, single source of truth, and tests for complex features / non-trivial bugfixes.
Dartway Clean Code โ the DartwayTeam rules
The set of mandatory rules for any Dart/Flutter code in DartwayTeam projects
(DartWay projects; dartway-first). These are not "recommendations" but a style contract โ the
team deliberately keeps the code clean so the anti-patterns listed below never pile up.
How to use this
Check against the rules while writing, generating, refactoring and reviewing code โ even a tiny snippet.
Every block: briefly why the rule exists, then โ (how not to) and โ (how to).
Before you hand code over โ walk the checklist at the end.
Part 1 โ the team's hard rules (the least obvious, broken most often). Part 2 โ general clean-code principles. Part 3 โ tests for the complex stuff.
Part 1. The team's hard rules
These rules are specific to the dartway stack and are broken most often. Keep them in mind first.
1.1 Naming: self-explanatory, at least 2 words
Why: a name must answer "what is this" without reading the type or the body. One-word and abstract names make the reader guess.
// โ what is this โ a model? a widget? a dto?
class User {}
final model = UserProfileModel();
final data = fetchData();
final order2 = getNewOrder(); // order2 โ what is that?
class DeliveryCard { final String s; final int n; final Function cb; }
// โ the name carries meaning
class UserProfileModel {}
final userProfile = UserProfileModel();
final fetchedProfile = fetchProfile();
final updatedOrder = getNewOrder(); // next to it: initialOrder
class DeliveryCard { final String deliveryStatus; final int itemsCount; final VoidCallback onTap; }
1.2 One responsibility per file; length is a soft guideline
Why: one file โ one reason to change. A model + repository + state + UI in one file can be neither read nor reused.
Length is the weakest of the indicators: under 200 lines we say nothing; >200 โ a reason to look closer; >350 โ a warning, the file has probably collected several responsibilities. A meaningful, coherent 300-line file beats a pointless chop into pieces with no responsibility of their own. Split by responsibility, not by the line counter โ all the more so because a feature file now also holds its DwFeatureSpec, and a good description costs a couple dozen lines.
// โ order_screen.dart: OrderItemModel + OrderRepository + OrderState + OrderListScreen
// โ separately:
// models/order_item_model.dart
// data/order_repository.dart
// state/order_state.dart
// ui/order_list_screen.dart
// โ also fine: a coherent 220-line screen with a single responsibility โ
// don't chop it into header_part_1.dart / header_part_2.dart to fit a limit
1.2a Imports: ../ means "next door", not "up the tree"
Why: a path made of dots says nothing. '../../../../ui_kit/ui_kit.dart' tells the reader neither
what is imported nor where it lives; package:my_app_flutter/ui_kit/ui_kit.dart tells both. The rule
is about distance, not about a blanket package: everywhere โ a relative import of the file next
to you is the clearer one.
Two ../ is the limit, and deep_relative_import (dartway_lints, warning) says so in the
editor. One or two steps read as "the feature next door"; three or four mean you left your group, and
a jump that big should be visible by name rather than counted in dots.
The limit doubles as a structure signal: if a sibling feature is suddenly four levels away, it is
not a sibling โ either the group fell apart, or what you are importing belongs in shared/ or
domain/.
1.3 Don't pass BuildContext / WidgetRef into services and logic
Why: service/domain code must not know about the UI and the widget lifecycle. It tears the layers apart and breeds "stale context".
// โ the service reaches into the UI
class PaymentService {
void processPayment(BuildContext context, double amount) {
ScaffoldMessenger.of(context).showSnackBar(...);
Navigator.of(context).pop();
}
}
void loadUserData(WidgetRef ref) { ref.read(userProvider); }
// โ the service returns a result, the UI decides what to show
class PaymentService {
Future<PaymentResult> processPayment(double amount) async { ... }
}
// in the widget: final result = await service.processPayment(amount);
// if (result.isSuccess) { ScaffoldMessenger.of(context)...; Navigator.of(context).pop(); }
The rule is about services, domain and logic โ not about UI code whose job is the widget tree.
A feature that opens as a sheet, a dialog or an overlay publishes itself as a static method on its
own widget, and BuildContext is its first parameter. That is the canonical shape, not an
exception squeezed past the rule:
// โ the feature's own widget owns how it is shown
class UserFormSheet extends StatelessWidget implements DwFeature {
static Future<void> showCreate(BuildContext context) =>
context.showAppBottomSheet(child: const UserFormSheet());
static Future<void> showEdit(BuildContext context, UserProfile userProfile) => ...;
}
// โ the same call, moved onto someone else's type to dodge the rule
extension UserFormExtension on BuildContext {
Future<void> showCreateUserForm() => showAppBottomSheet(child: const UserForm());
}
An extension on BuildContext that opens your own screens is an antipattern. It reads as
compliance and costs more than the parameter ever would: the feature ends up with two public files
instead of one (the extension at the root, the widget hidden in widgets/), the entry point stops
being the widget, and context.showCreateUserForm() no longer says which feature it opens.
What this does not forbid: the framework's navigation extensions (context.pushTo,
context.goNamed) are the supported way to move between routes, and the UI Kit's own presentation
primitives (context.showAppBottomSheet(child: โฆ), theme and l10n accessors) are exactly where a
BuildContext extension belongs โ they take any child and name no feature. The line is whether the
extension names a screen of yours: presentation chrome on BuildContext is fine, a feature on
BuildContext is not.
1.4 No _buildXxx() methods that return a widget
Why: private build methods get no const, are not reused, and break rebuild boundaries. A widget is a class, not a method.
// โ
class ProfilePage extends StatelessWidget {
Widget _buildHeader() => Container(...);
Widget _buildStats(int orders, int reviews) => Row(...);
@override
Widget build(BuildContext context) => Column(children: [_buildHeader(), _buildStats(10, 5)]);
}
// โ separate widget classes
class ProfilePage extends StatelessWidget {
@override
Widget build(BuildContext context) =>
const Column(children: [ProfileHeader(), ProfileStats(orders: 10, reviews: 5)]);
}
// ProfileHeader / ProfileStats โ each in its own file (see 1.8)
1.3a No functions outside classes โ only class methods or extensions
Why: a top-level function is a name dangling in the library namespace: you can't find it from the type it works with, the IDE won't suggest it after a dot, and you have to import it separately from whatever it belongs to. Logic bound to a type belongs to that type.
// โ a free function โ the name doesn't show what it relates to
CourseLockState resolveCourseLockState({required UserCourse? userCourse, ...}) { ... }
// โ a factory on the type itself โ found through the dot from it
class CourseLockState {
factory CourseLockState.resolve({required UserCourse? userCourse, ...}) { ... }
}
// โ or an extension, if the type is someone else's
extension UserCourseAccess on UserCourse {
bool isExpiredAt(DateTime now) => accessUntil.isBefore(now);
}
Where things go: creates a value of its own type โ factory constructor; answers a question about an existing value โ method or getter; the type is someone else's (a model from the generated client, a framework type) โ extension; you need a shared utility with no type of its own โ a static method on an owner class, not a free function.
Exactly two exceptions, both forced:
codegen provider entry points โ if the project does use riverpod_generator after all (by default we don't, see the codegen policy in CLAUDE.md): a @riverpod function must be top-level, the generator requires it;
main() and similar runtime entry points.
1.3c A private widget method that computes data is an extension in logic/
Why: rule 1.4 forbids _buildXxx() that return widgets, and this is its data half. A private
widget method that transforms the domain (filters a list, maps models into kit parameters,
computes a derived value) is the same logic hidden from the type it works with: you can't find it
from the model, can't reuse it in a neighbouring widget, and can't cover it with a test without
spinning up the whole tree.
// โ domain mapping hidden inside a private widget method
class AdminChatsFiltersBar extends ConsumerWidget {
List<AdminSelectOption<int>> _postOptions(List<ChatPostListDto> posts) => [
for (final post in posts)
if (post.commentToPostId == null && post.title != null)
AdminSelectOption(value: post.id, label: post.title!),
].take(20).toList();
}
// โ an extension next to the feature, in logic/ โ found from the list through the dot
extension ChatPostFilterOptions on List<ChatPostListDto> {
List<AdminSelectOption<int>> get commentParentOptions => [ ... ];
}
// in the widget: options: posts.commentParentOptions
The boundary is simple: the method builds a widget โ a separate widget class (1.4); the method
computes data โ an extension in the feature's logic/ (1.3a). What stays in build is a call through the dot.
A getter that reads only the widget's own fields and computes nothing
(bool get _hasImage => imageUrl?.isNotEmpty ?? false) needs no extraction โ it is about the widget itself.
1.3b Write a state data class by hand, not with freezed
copyWith and == over five or six fields are twenty lines, written once and read without any
extra knowledge. In exchange freezed charges you build_runner in the edit loop and a generated file
next to every class.
The exception where the generator really pays off: union types (several constructors of one
sealed type with an exhaustive switch). Bringing it in for a single data class โ no.
1.4a A widget in a local variable used once is the same _buildXxx()
Why:final content = Column(...) that is inserted below in a single place is the same tree
break as _buildXxx(): the reader has to keep in mind where the variable is declared and where it
is used. Inline it right where it belongs.
Exception: a variable is worth introducing if it is used twice or more (two branches of a
condition) or if between the declaration and the use there is a computation you would otherwise have to repeat.
1.4b We don't carry commented-out code
Delete it. History lives in git, while a comment rots silently: one file in a production project held 119
commented-out lines out of 306 โ a whole widget written against an API that no longer
exists. You can't revive that anyway, and everyone has to read it.
1.5 No ref.invalidate(...) for refreshing
Why:invalidate is a blunt reset that takes down related providers and makes the UI flicker. Update the state through the proper state mechanism (in dartway โ refresh on DwRepository/the state provider, re-fetching the data).
1.6 Don't look widgets up in the tree via GlobalKey
Why:GlobalKey().currentState reaches into someone else's state past state management. Drive the data through a provider/controller instead of poking the tree.
// โ
final nameFieldKey = GlobalKey<FormFieldState>();
void validate() => nameFieldKey.currentState?.validate();
// โ form state lives in a provider/controller; validate by data, not by widget
final isNameValid = ref.read(signUpFormProvider).isNameValid;
1.7 No outer padding (padding/margin) inside a widget
Why: outer padding is the responsibility of the parent that places the widget. If a widget gives itself outer margins, it can't be reused in another context.
// โ the widget gives itself outer padding
class ProductCard extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Container(...));
}
class ActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) =>
Container(margin: const EdgeInsets.all(12), child: ElevatedButton(...)); // margin = outer padding
}
// โ the widget draws only itself; the parent sets the padding
class ProductCard extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(...); // inner content padding is fine
}
// parent: Padding(padding: ..., child: ProductCard()) / ListView(padding: ...)
The rule holds when refactoring someone else's code too. Rewriting a widget โ the outer Padding
is not "kept as it was", it moves to the caller (itemBuilder, Column, ListView.padding).
Otherwise the refactoring legalizes the violation: the file got cleaner, and the padding stayed inside.
1.7a A widget doesn't decide how much space it takes
The same principle as 1.7, but about size: how much space a widget gets is the parent's call.
A widget that inflates itself breaks on the first reuse โ and it breaks at runtime, the analyzer
says nothing about it.
// โ the widget assigns itself a size and demands a particular parent
Widget build(BuildContext context) => Expanded(child: content); // will crash outside Row/Column
Widget build(BuildContext context) => SizedBox(height: double.infinity, child: content);
// โ the widget draws its content; space is given by whoever inserted it
Widget build(BuildContext context) => content;
// parent: Expanded(child: MyWidget()) / SizedBox(height: 200, child: MyWidget())
A special trap: Expanded requires a flex parent. A widget living in a screen's Column gets
inserted by the very same code into a bottom sheet, a dialog or a SingleChildScrollView โ and the app
crashes for the user. Before changing a widget's sizing wrapper, walk all its callers.
The exception is a widget whose whole point is its size (AppEventCard.compact with the feed's fixed width):
then the size is declared in the kit and visible from the constructor name, not hidden in build.
1.8 A widget with standalone value is a public class in its own file
Why:_MessageBubble in chat_message_list.dart is a separate entity of the feature (message rendering, its own behavior) that was hidden as private: it can't be reused or tested from the outside. Extract such things into their own file as a public class.
// โ chat_message_list.dart contains class _MessageBubble and class _DateSeparator
// โ message_bubble.dart -> class MessageBubble; date_separator.dart -> class DateSeparator
Exception โ a trivial presentational helper used once in the very same file (e.g. _CounterTile inside admin_counters.dart): it has no value for reuse or a separate test, moving it into a public file is pure noise. Such a private class is allowed. The criterion: a feature entity you'll want to reuse/test โ a public file; a local layout detail of a single screen โ may stay private next to it.
1.9 Don't create a pass-through widget: if it decides nothing, inline the kit widget
Why: a pass-through is a class that only re-assembles its own parameters into a single kit widget. The reader has to open an extra file just to learn there is nothing in it, and the feature grows a folder of such files.
// โ course_locked_stub_card.dart โ every field goes straight out as is
class CourseLockedStubCard extends StatelessWidget {
const CourseLockedStubCard({required this.child, required this.topInset});
...
Widget build(context) => Padding(
padding: EdgeInsets.only(top: topInset),
child: ChatCardContainer.bottomSheetSurface(child: child),
);
}
// โ the same thing right at the call site โ the file and the class simply don't exist
How to tell a pass-through from a widget you need โ by what it does with the domain:
a mapper turns a model or a domain enum into kit parameters (CommunityEventCard โ a model into texts and an image url; picking a constructor by CourseLockReason). That is work, and it must live in the feature: the kit knows nothing about models. We keep it;
a pass-through forwards its own parameters and adds at most a padding constant to them. There is no work. We delete it and inline the kit widget.
The sign that gives a pass-through away at the door: its only import is ui_kit.dart โ it knows nothing about the domain, so it has nothing to decide.
If such a widget really is needed by many (the same wrapper in five places) โ that is no reason to breed a pass-through in the feature, it is a reason to add a constructor in the kit (ChatCardContainer.bottomSheetSurface): the meaning moves to where the look lives.
Part 2. Clean code principles
Well-known principles. Here โ how they look in Dart/Flutter and what to avoid.
2.1 SRP โ Single Responsibility
Why: a class that loads data, caches, validates, formats and shows a SnackBar cannot be changed safely.
Why: adding a new type must not require editing old code. Extend with an abstraction, not with another if/switch branch.
// โ a new channel means going inside send()
void send(NotificationType type, String message) {
if (type == email) {...} else if (type == push) {...} else if (type == telegram) {...}
}
// โ abstract class NotificationChannel { void send(String message); }
// EmailChannel / PushChannel / TelegramChannel โ a new channel = a new class, send() is untouched
2.3 LSP โ Liskov Substitution
Why: a subclass must work everywhere the parent works. Throwing UnsupportedError from an overridden method is a broken contract.
Why: a "fat" interface forces you to implement what you don't need through stubs/exceptions.
// โ abstract Worker { writeCode(); reviewCode(); designUI(); manageTeam(); deployToProduction(); }
// class JuniorDeveloper implements Worker { designUI() => throw UnimplementedError(); ... }
// โ narrow roles: Coder / Reviewer / Designer / Manager โ implement only the ones you need
2.5 DIP โ Dependency Inversion
Why: high-level code must depend on an abstraction, not on a concrete implementation. A FirebaseโSupabase move must not touch the screens.
// โ final authService = FirebaseAuthService(); // nailed to the implementation
// โ depend on abstract class AuthService; inject the concrete one through a provider/DI
final authService = ref.read(authServiceProvider); // -> FirebaseAuthService under the hood
2.6 KISS โ simpler
Why: extra complexity = extra bugs. Don't write 15 lines and a "strategy pattern" where one line is enough.
// โ 15 lines with a loop just to check emptiness; Strategy+Context just to join two strings
// โ items?.isEmpty ?? true; '$first $last';
// โ BuilderโLayoutBuilderโAnimatedContainerโMediaQueryโDefaultTextStyle for a single Text
// โ Text(text)
2.7 DRY โ don't repeat yourself
Why: a copy-pasted card/logic diverges at the very first change. Extract into a shared widget / extension / domain.
// โ three identical Container cards in a row via Ctrl+C; the same mapping in ViewModel and ExportService
// โ OrderStatusCard(title: ..., status: ..., color: ...);
// extension ActiveOrderFilter on List<OrderItemModel> { List<String> get exportTitles => ...; }
2.8 YAGNI โ don't build for later
Why: 20 fields "in case they come in handy" and an abstraction with a single implementation are dead weight you have to maintain.
// โ UserProfileState with tiktokHandle/linkedInUrl/isInfluencer/... when only name and email are used
// โ abstract BaseAnalyticsProvider + a single FirebaseAnalyticsProvider
// โ keep only the real fields; introduce the abstraction when a second provider appears
2.9 Law of Demeter โ don't reach into someone else's guts
Why: the chain a.b.c.d means the caller knows the entire internal structure of other objects. Any link will break.
Why: a widget with an HTTP request, a discount calculation and validation can be neither tested nor reused. The UI only displays.
// โ _ProductPageState: http.get(...) + calculateDiscount(...) + canAddToCart(...) right in the State
// โ requests -> repository; calculations/rules -> domain; State only holds and shows the data
2.12 Fail Fast โ don't swallow errors
Why: an empty catch hides a bug forever. Log it, rethrow it, or handle the specific error.
Why: a custom LRU cache for 50 users is complexity without a reason. Measure first, optimize after.
// โ a hand-rolled LRU with _accessOrder and _maxCacheSize = 1000 for a list of 50 names
// โ a plain Map (or no cache at all) until the profiler shows a real problem
2.14 Tell, Don't Ask
Why: don't pull an object's fields out to compute outside โ let the object compute itself. Logic lives next to the data.
// โ outside: sum up cart.itemPrices, subtract cart.promoDiscount, clamp...
// โ cart.total; // ShoppingCart itself knows how to compute its total
2.15 Avoid God Object
Why: a class holding auth + orders + cart + profile + settings + navigation + analytics is the whole app in one file.
Why:if (distance > 50) and status == 'pndng' โ the compiler catches no typo, the meaning of the number is unknown. Names and enums.
// โ return weight * 3.5 + 299; if (status == 'pndng') ...
// โ static const longDistanceBaseFee = 299; enum OrderStatus { pending, delivered, inTransit }
// switch (status) { case OrderStatus.pending: ... } // the compiler demands every branch is covered
2.17 Single Source of Truth
Why: a local copy of global state falls out of sync โ you forget to write it back, and the UI lies.
// โ initState() { userName = GlobalAppState.userName; } save() { GlobalAppState.userName = userName; }
// โ the widget reads and writes directly through the provider โ one source of truth
final userName = ref.watch(userProfileProvider.select((p) => p.name));
Part 3. Tests for the complex stuff
Why: complex logic can't be checked "by eye" โ it breaks on edge cases and quietly degrades over time. A test pins down the expected behavior and catches regressions. The threshold is behavior complexity, not the mere fact of a change.
What we cover with tests:
Complex features and non-trivial logic โ calculations, business rules, state machines, money (e.g. wallet/payments).
Edge cases and rollback/degradation scenarios ("downgrade": a business profile expired โ the badge was removed, a subscription was cancelled, the balance went negative).
Any bugfix of non-trivial behavior.
What we do NOT test: cosmetics โ recolored a button, fixed a padding, renamed something. A test for the sake of a checkbox contradicts KISS/YAGNI.
A bugfix is strictly "cause โ fix":
First a test that reproduces the bug. It fails โ that failure is the localization of the cause.
You fix the code until the test goes green.
The test stays in the repo as a regression guard, so the bug does not come back.
The test level follows where the behavior lives (the level itself is not dogma):
Logic (domain/services/state/repository) โ a unit test. Most of it lands here โ the logic is extracted out of widgets anyway (see 2.11 SoC).
Behavior in the UI โ a widget test for the key scenario.
// โ a complex wallet calculation is patched "by eye", no tests โ users catch the regression
// โ a bugfix without a test: the cause is not pinned down, in a month the bug is back
// โ reproduce-first bugfix: a red test on the cause โ fix โ it stays as a regression test
test('wallet does not go negative when charged more than its balance', () {
final wallet = Wallet(balance: 100);
expect(() => wallet.charge(150), throwsA(isA<InsufficientFundsException>()));
});
Capstone: everything wrong โ how it should be
// โ 1-word name + build method + context/ref as parameters + invalidate + outer padding + duplication
class Page extends ConsumerWidget {
Widget _buildItem(BuildContext context, WidgetRef ref, dynamic d) => GestureDetector(
onTap: () { ref.invalidate(someProvider); Navigator.of(context).pop(); },
child: Padding(padding: const EdgeInsets.all(16), child: Text(d.toString())),
);
@override
Widget build(BuildContext context, WidgetRef ref) =>
Column(children: [_buildItem(context, ref, 'one'), _buildItem(context, ref, 'two')]);
}
// โ meaningful name + separate widget class + data through state + padding outside + list without copy-paste
class ItemsListPage extends ConsumerWidget {
const ItemsListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(itemsStateProvider);
return Column(children: [for (final item in items) ItemTile(item: item)]);
}
}
// item_tile.dart -> class ItemTile (outer padding set by the parent/ListView, refresh through the notifier)
Checklist before handing code over
Names are meaningful, โฅ2 words; no model/data/list/s/n/cb/order2.
File โ one responsibility (model/repository/state/UI separately); >200 lines โ look closer, >350 โ probably time to split (but by responsibility, not by the counter).
No functions outside classes โ factory/method/extension; the only exceptions are @riverpod entry points and main() (ยง1.3a).
No widget in a variable used once (ยง1.4a) and no commented-out code (ยง1.4b).
No pass-through widgets โ a class that only forwards its own parameters into a kit widget and knows nothing about the domain (ui_kit.dart its only import) is not created: inline the kit widget, and turn a repeating wrapper into a kit constructor (ยง1.9).
NoBuildContext/WidgetRef in the parameters of services and functions โ and no extension on BuildContext that opens the app's own screens: showing a feature is a static method on that feature's widget (ยง1.3).
No_buildXxx() methods returning a widget โ those are separate widget classes (ยง1.4).
No private widget methods that transform the domain โ those are extensions in the feature's logic/ (ยง1.3c).
Noref.invalidate(...) โ refresh through the state.
NoGlobalKey for looking widgets up in the tree.
No outer padding/margin inside a widget โ the parent sets the padding (ยง1.7). When refactoring someone else's widget the outer padding moves to the caller instead of being "kept as it was".
NoExpanded/SizedBox(โฆ: double.infinity) at the root of build โ the parent gives the widget its space (ยง1.7a).
No private widget classes (_Foo) in public feature files.
A building block โ a widget with no product behaviour to describe โ lives in lib/shared/ with a doc comment, not in a zone with an empty DwFeatureSpec.
Imports: own internals and sibling features are relative and no deeper than two ../; core/data/domain/shared/ui_kit/another zone are package: (ยง1.2a).
The provider is the first thing in its file (or lives in the feature's root file), not appended after the notifier that implements it.
The complex stuff (non-trivial logic/behavior, money, "downgrade" rollbacks) is covered by a test; a bugfix โ first a failing test on the cause, then the fix. We don't test cosmetics.
SOLID, KISS, DRY, YAGNI, Law of Demeter are respected.
Composition instead of deep inheritance; logic is not in the UI (SoC).
Errors are not swallowed (fail fast); no premature optimization.
Tell-don't-ask; no god objects; no magic numbers/strings (enum/const).
One source of truth โ no local copies of global state.