Making an Initializer for Flutter Projects. Clean and SOLID

    How many times have you launched flutter create, then deleted the good old “Counter App”, added the lint rules to the analyzer, and configured the folder and file structure? I would assume it happens pretty often. Now, imagine a business with dozens of commercial projects and hundreds of in-house projects: creepy, right? The onset of each new project is a process you can’t streamline if you’re a Flutter developer.

    If you haven’t faced the challenge of initialization, then I’m sure you’ve had an idea of writing your own tool or, say, a pet project. How do you approach development, anyway? What methods do you use and what stages does it involve? 

    Hi! I’m Joseph, and I’m a Flutter developer at Surf. Using our starter as an example, I’m going to tell you how to make your own tool the right way. The tips I’m going to share are pretty universal and could be applied to any project.

    Designing a console utility

    The goal is to design a console utility that would interact with both users and the external layer, the Git repository. The key requirement is highly protected variations. The variations need to be embraced, updating our “CLI tool” as painlessly as possible. 

    This project task may seem pretty trivial from the outside. The solution in the form of a script will fit in one file: why complicate things? In fact, the “script” wouldn’t even last a few projects: such an approach is ineffective when creating complex and long-term systems.

    Staying in line with SOLID principles and clean architecture helps create not a script, but rather a trusted tool which could be improved and updated. Let’s now stop and break down the basic concepts, so that we can speak the same language further on.


    SOLID is an abbreviation of five basic principles in object oriented programming:

    • single responsibility,
    • open-closed,
    • Liskov substitution,
    • interface segregation,
    • dependency inversion.

    It means that all elements of a program have to:

    • Have single responsibility: one class can’t be responsible for radically different things.
    • Be open for expansion, but not change.
    • Comply with the hierarchy: the higher levels can depend on the lower ones but not the other way around.
    • Have separate interfaces without strong dependence on a particular implementation.

    Clean architecture

    Clean architecture is a greater abstract: it is used on the strategic level while designing the architecture on a project. It entails independence from frameworks, interfaces, and any other external agents. It means that software has to be separated into logical but not tightly coupled layers: thanks to that, software can be modified and upgraded while still being able to function. 

    If you combine these paradigms, you can avoid a lot of mistakes earlier on, while you’re still designing the solution: patterns act as a blazed trail. You could say that it’s a recipe for avoiding pitfalls in development: something to keep you from falling into old traps.

    Interactions and connections

    Let’s draw a diagram of all the things our tool will interact with and the connections it will use for that purpose.


    Or configurations. In configurations, we set custom parameters required for the project. That could be done by means of filling out a file or in the process of an interaction between CLI and a user.

    CLI tool

    The tool is launched when a Flutter developer creates a project. The main purpose of the script is putting together the configurations and creating a project. The backbone responsible for generation is the repository on GitHub.

    Project template

    The uniform standard for projects. You could say it acts as the point of truth where the best practices, analyzer rules, and packages used in development are stored. 

    Architecture: links between classes and interfaces

    In the world of OOP and clean architecture, a project is a complex system of links and dependencies between numerous groups of elements and parameters. Keeping the entire structure in mind is a hopeless and, mostly, useless undertaking. The best practice is to start by plotting a diagram of all dependencies. It’s a diagram that illustrates links between classes and interfaces inside a piece of software. 

    Using the legend, we can add the elements of interaction from UML that we are using: has, implements, extends, and passes. Entities are represented by two types of elements here: class and interface.

    Why do we need all of this? It’s simple: no matter what tool you’re designing, it’s crucial to identify and eliminate “red flags”. These could be overloaded God-classes, implicit dependencies, a disorganized hierarchy of layers, and so on. The sooner we detect an issue, the quicker we can fix it, reducing the total cost of it. 

    The diagram of dependencies works perfectly for this task: you can clearly see what the architecture looks like and whether it has any kluges.

    The processes “within”

    The diagram of dependencies can show you the links between the layers, but not their content. To understand the processes going on inside your tool, you’ll need a Swimlane diagram.

    Swimlane is used in process diagrams, describing what happens throughout a specific part of a process and how it does that. The “swim lanes” are positioned horizontally or vertically and are used to group processes or tasks in accordance with the responsibilities of these resources, roles, or units.

    In our example, these are separated with respect to the order they are called in and the way abstraction layers are arranged in the architecture. Thus, they are divided top-down:

    Command > Creator > Job > Repository > Service

    If you look at the legend, you can see a standard block of a diagram:

    And here’s what Swimlane looks like for our algorithm:

    Control elements (arguments and parameters) come from the top down. 

    • On the left, there’s the entry point: how we got here. 
    • On the right, there are calls: where we’re going. 
    • At the bottom, there’s the exit point, i.e., artifacts, which could have been created as a result of the process.

    As soon as you plot the diagram, you can clearly see the path that the algorithm “treads”. It’s not just links on a diagram of dependencies — it’s a well-defined process divided into stages with all activities specified for every layer. The most detailed descriptions are given to Jobs, the “wheel horses” of our tool. Here, we can see the project config defined first, followed by creating an archive, which is then unpacked with its contents renamed. And finally, we have the project.


    The major part of it is about taking time before development to answer the questions that arise in the process. Avoiding that will bring the cost of your mistakes up. Eliminating most of the potential uncertainties in advance is going to make the project much easier to develop and support.


    /// Describes a new project that is being created.
    /// Consists of values & parameters, that are being inserted
    /// into a new project when it's being created by the user. User
    /// defines those values & parameters as [ConfigParameter]s
    /// whilst interacting with CLI.
    class Config { /* ... */ }

    Config is a base class that declares the definitions of configurations used in a new app. Config is filled with unique fields, which will be used to configure and initialize the project.

    In this object, all the important fields are declared through a Config Parameter—another object storing the simplest value of a specific parameter and its validation logic. Essentially, it’s somewhat similar to Value Object in the Domain Driven Design (DDD) paradigm: that way, you can get more control over the values stored in the configuration.

    /// Directory, in which a new project is created.
      final ProjectPath projectPath;
      /// Name of new project.
      /// See also:
      /// *
      final ProjectName projectName;
      /// Application Label (name).
      /// See also:
      /// *
      final AppLabel appLabel;
      /// Application ID.
      /// See also:
      /// *
      final AppID appID;


    /// Builds [Config].
    /// As a whole, it is based on a builder-pattern. It functions as an easier
    /// method of building [Config] objects, adding its [ConfigParameter]s
    /// on the way.
    abstract class ConfigBuilder {
      /// [Config] private instance.
      /// Default to an empty config with empty parameters.
      Config _config = Config.empty();
      /* Builder methods */
      /// Returns [Config] instance.
      Config build() => _config;
      /// Builds [Config] with given parameters.
      Config buildWithParameters({
    	required String projectPathValue,
    	required String projectNameValue,
    	required String appLabelValue,
    	required String appIDValue,
      }) {
    	return build();

    Builder is a creational pattern: acts in a step-by-step manner and makes building objects easier. It comes in handy when you need to initialize complex objects with multiple fields and parameters.

    Config Builder implements this pattern and makes it easier to create an instance of Config. The interface contains a range of builder methods, each responsible for initialization of a specific Config Parameter (hidden behind the commentary). Besides that, the class contains a configuration instance containing empty parameters by default.


    /// '[Config]'-MVP like builder, used for initial [Creator.start].
    /// Consists of:
    /// [ProjectName],
    /// [ProjectPath],
    /// [AppLabel],
    /// [AppID].
    /// Is bare minimal of a project entity & its builder only used for
    /// quick & easy [Creator.start].
    class MinimalConfigBuilder extends ConfigBuilder { ... }

    Minimal Config Builder implements the builder interface by overriding builder methods. The goal of this specific builder is to create a simple Config in the style of an MVP, i.e., using the baseline minimum of parameters. So far, this builder is the only one in the project; however,  new builders could be added as the project evolves.


    /// Interface for Project creation.
    abstract class Creator {
      /// Main [Creator] entry-point.
      Future<void> start() async {
        final config = await prepareConfig();
        return createByConfig(config);
      /// Retrieves [Config] from somewhere.
      Future<Config> prepareConfig();
      /// Creates Project by given [Config].
      /// Runs series of [Job]s to do so.
      Future<void> createByConfig(Config config);

    Creator—”creates” the project. It specifies the algorithm of operations that convert the project from an initial template arrangement into a specific one. What’s more, the Creator acts as the entry point, taking care of the way we launch the CLI utility. It could be either the “Interactive CLI creator” to interact with users as the project is created or the “Automatic Creator” to generate it automatically following a prefabricated config file.

    We’ve chosen the pattern called “strategy”. Thanks to that, the utility is able to run various Creators from a single entry point. It means that the Creator can implement various behavior scenarios while still preserving the common interface. In reality, it’s an extremely useful property that complies with the LSP (Liskov Substitution Principle) in SOLID and makes it easier to handle the code base in the future.


    /// Atomic task, which does something and returns `Object?` on completion.
    /// [Job]'s are used for the project generation process. They are top-level entities,
    /// which define several technical steps of creating a new project. [Job]'s are
    /// expandable. Meaning, that series of more [Job]'s can create more complex
    /// structure.
    abstract class Job {
      /// Executes specific task for project template creation.
      /// Returns `Object?`
      Future<Object?> execute();

    Job is an atomic task that performs a specific action. For example, a Job can be in charge of downloading the project archive or renaming its constituent files. The thing about Jobs that makes them so convenient is that when the business requirements for the project template change, you can easily adjust one of the tasks, move the executions around, or add a completely new thing without creating any conflicts with the previous Jobs.

    /// [Job] requires [Config], as a project-describing entity.
    abstract class ConfigurableJob extends Job {
      /// Instance of [Config].
      /// Holds [Job]-specific instance of [Config], required for
      /// [Job.execute] & project creation process.
      late final Config config;
      /// Sets up [Job] before its' [Job.execute].
      /// Requires [Config].
      void setupJob(Config config) {
    	this.config = config;

    Besides the standard Job, we’re extensively using its subtype, Configurable Job. The essence of the object doesn’t change, since it’s still in charge of executing an atomic task. However, now it needs a Config to do the task. With a pattern called Object Injection and a method called “setup job”, we pass the instance, which our Job can use to do the task.

    You could say that Job is in a way implemented in line with a pattern called “a chain of events”: to stop Creator from turning into a God-class and still keep it in charge of all the specifics that go into creating a project, we have set aside a series of Jobs that build the project gradually, “brick by brick”. 

    A series of Jobs:

    1. Puts together configurations of a project obtained from the user.
    2. Downloads the template archive.
    3. Unpacks and deletes the archive.
    4. Substitutes the template data with the values in the config.

    Tests and rollout

    All we’ve got left is to test the tool, post it in, open the repository, and distribute it to the entire department. Remember to write a guide: it’ll help you use the starter in the future.

    The process of creating a project is critical, because initialization is when we lay the groundwork with the key approaches to development: architecture, state management, and lint rules. That’s why documenting the process is extremely important.

    Tip: Don’t start coding right away

    Lastly, I’d like to give you some advice: if you want or need to create your own tool, by no means start coding it straight away. You might want to spend some time designing it: understand what you’re trying to address, choose the architecture, and consider the ways you can implement it. 

    Novice developers often feel like they need to write code as soon as possible. That leads to poor solutions that are impossible to support and unable to stand the test of time. Try sketching out a rough plan for your implementation; draw a couple of diagrams: it will make your tool much easier to create and use further down the road.