The Amplication Plugin System

Amit Barletz
Amit Barletz
May 2, 2023
The Amplication Plugin SystemThe Amplication Plugin System

The plugin system is one of the many exciting features released in version 1.0.0 of Amplication. As an open-source company, we're driven by the developer community's needs. The idea to develop a plugin system came from a specific call from developers: to have more flexibility in the generated application.

Some of the most debated topics among software developers today are best practices, principles (like SOLID, DRY, and YAGNI), and whether to use opinionated frameworks and libraries. Those familiar with our generated application software have probably noticed it is very opinionated and follows the NestJS best practices. However, we want our platform to be the answer for everyone—and we want to stay responsive to the developer community. So we sat down to consider best practices for enabling the extension and manipulation of Amplication-generated applications to make them less opinionated and more flexible. The result? Amplication's new, community-driven solution for plugin system architectures.

Let's dive into what plugins are, explore how Amplication handles them, showcase how we build them, and explore some of the considerations required to create an effective, error-free plugin.

Plugins in Amplication

Plugins are software additions that allow the customization and enhancement of computer programs, applications, and web browsers. Amplication's plugin system allows us to add new features or manipulate our generated application's behaviors. Our Data Service Generator (DSG) gathers all the data that the user configures in the UI or CLI, including entities and their fields, roles, permissions, and services settings. The DSG then uses these values as inputs for its functions; each function is responsible for code generation in a specific area, such as CreateEntityController, CreateEnitityResolver, CreateServerDockerCompose, and more.

To build this framework for plugins, we've exposed each function to plugin developers, providing all the data and context known at that point and allowing the system to manipulate the data and impact the event's results.

Before and After Functions

All events share the same interface, which contains two functions: before and after. These functions are responsible for different steps in an event's lifecycle. Here's how they look:

export interface PluginEventType<T extends EventParams> {
  before?: PluginBeforeEvent<T>;
  after?: PluginAfterEvent<T>;
}

For each event, the type of EventParams is different, providing access to relevant data for the specific event.

export interface EventParams {}

export type PluginBeforeEvent<T extends EventParams> = (
  dsgContext: DsgContext,
  eventParams: T
) => Promisable<T>;

export type PluginAfterEvent<T extends EventParams> = (
  dsgContext: DsgContext,
  eventParams: T,
  modules: ModuleMap
) => Promisable<ModuleMap>;

In the before and after functions, developers can access the context and the event parameters. We use the context to gather data shared between events. The event parameters are used to manipulate the default behavior by passing values that differ from the default.

The after function also gives us access to the generated modules via the ModuleMap class. This parameter can be helpful when a developer wants to restructure the generated modules into a different folder structure.

/**
 * ModuleMap is a map of module paths to modules
 */
export class ModuleMap {
  private map: Record<string, Module> = {};
  constructor(private readonly logger: BuildLogger) {}

  /**
   * Merge another map into this map
   *
   * @param anotherMap The map to merge into this map
   * @returns This map
   */
  async merge(anotherMap: ModuleMap): Promise<ModuleMap> {...}

  /**
   * Merge many maps into this map
   * @param maps The maps to merge into this map
   * @returns This map
   * @see merge
   */
  async mergeMany(maps: ModuleMap[]): Promise<void> {...}

  /**
   * Set a module in the map. If the module already exists, it will be overwritten and a log message will be printed.
   * @param module The module (file) to add to the set
   */
  async set(module: Module) {...}

  /**
   * @returns A module for the given path, or undefined if no module exists for the path
   */
  get(path: string) {...}

  /**
   * Replace a module in the map. If the module does not exist, it will be added to the set.
   * @param oldModule The module to replace
   * @param newModule The new module to replace the old module with
   */
  replace(oldModule: Module, newModule: Module): void {...}

  /**
   * Replace all modules paths using a function
   * @param fn A function that receives a module path and returns a new path
   */
  async replaceModulesPath(fn: (path: string) => string): Promise<void> {...}

  /**
   * Replace all modules code using a function
   * @param fn A function that receives a module code and returns a new code
   */
  async replaceModulesCode(fn: (code: string) => string): Promise<void> {...}

  /**
   * @returns An array of modules
   */
  modules(): Module[] {...}
}

Amplication will continue generating code as normal when no lifecycles event is provided and no parameters are changed.

Utility Functions: skipDefaultBehavior

In addition to the specific parameters required for each event, we also provide a global context object that can help the developer access other data they need, such as the list of entities, roles, other services in the project, and all files generated thus far. It also has a util property consisting of utility functions. One of these utility functions is skipDefaultBehavior.

context.utils.skipDefaultBehavior = true;

The default behavior won't execute when the skipDefaultBehavior is set to true in the before function, so developers must provide their logic. Developers can do so after the skip in the before or after functions.

It's important to remember that using skipDefaultBehavior without providing an alternative functionality can cause unexpected behavior—for example, the skipped event could generate files imported by other files, so it must be used wisely and carefully. As Uncle Ben said, "With great power comes great responsibility."

Events Hierarchy

It's essential to consider the execution order of the events in the DSG (Figure 1.) There are two main events in the DSG service: one for the server generation and the other for the admin-ui creation.

These main events include other sub-events to create the server and admin-ui's files. (In Amplication's source code, these files are sometimes called modules.) The internal events within the server and admin-ui events are synchronous, so the execution order of plugin code must be accounted for when developing them.

Execution order of the events in the DSG
Figure 1: Execution order of the events in the DSG

Let's break down this image into three component types:

  • A single DSG event with a plugin wrapper is indicated by a rectangle with no forward arrows. The title represents the corresponding event name.
  • "Create message broker" is a function with a plugin wrapper that executes other events of the message broker creation. As in the previous category, the title corresponds to the event name.
  • Resources creation (in a gray rectangle "for each entity") is a function that executes the events of the resources' creation, such as services, controllers, and resolvers.

Automate and standardize
backend development.
Get a demo

A Quick Breather

Let's take a breather before we go to how to develop a plugin. Deep breath in, 1... 2... 3... 4... 5..., deep breath out, 1... 2... 3... 4... 5...

A breathing exercise

If you're interested in the Amplication Plugin System and would like to see more of what the platform offers. In that case, we invite you to check out the Amplication GitHub repository.

We've built our platform with open-source technologies, and your support is crucial to our success. If you find our platform helpful, we would greatly appreciate it if you could take a moment to 🌟 us on GitHub. Your support helps us to continue developing and improving the platform for everyone. Additionally, if you want to contribute to the project, we welcome pull requests and issues on our GitHub repository. We appreciate your support!

Developing a Plugin

As we've seen, plugins are complex beasts with interdependent parts that must be kept in mind during their development process, including wrappers, before and after lifecycle events, event parameters, context, skipDefaultBehavior, and event hierarchy. Understanding the importance of each part can help us develop an effective and error-free plugin.

Amplication's Development Process

In this last section, we want to share the plugin development process used by our team at Amplication. Before development begins, we take the following steps:

  1. Generate a service with Amplication.
  2. Apply the changes required manually on the resulting source code that Amplication generated:
    • Add any missing functionality.
    • Manipulate the existing functionality.
  3. Create a PR to see the changes, and use the result to design the plugin.
    • Determine which events are needed. For example, if we use a new npm package, we must update the package.json file.
    • Decide how to use events with the before and after lifecycle functions. We know that CreateServerPackageJson is one of the first events that run, so we must capture that event in the before function and add the new npm package.
    • Take into consideration exception errors caused by the plugin's functionality or limitations.

Plugin Development Workflow in Action

To illustrate this development workflow, let's look at the MySQL plugin. But first, let's review the functionality of a database connection:

  1. We already have the functionality of a database connection using Prisma as an ORM.
  2. Prisma supports MySQL as a provider; change the provider on the Prisma schema accordingly.
  3. We use environment variables for the Prisma schema database's URL and the DockerCompose values; we change the environment variables and their values where needed.

Now, let's translate the above steps to events:

register(): Events {
    return {
      CreateServer: {
        before: this.beforeCreateServer,
      },
      CreateServerDotEnv: {
        before: this.beforeCreateServerDotEnv,
      },
      CreateServerDockerCompose: {
        before: this.beforeCreateServerDockerCompose,
      },
      CreateServerDockerComposeDB: {
        before: this.beforeCreateServerDockerComposeDB,
        after: this.afterCreateServerDockerComposeDB,
      },
      CreatePrismaSchema: {
        before: this.beforeCreatePrismaSchema,
      },
    };
  }

During Step 3 of the development workflow, we encountered an unexpected behavior: Prisma's MySQL provider does not support lists of primitive types. In the following function (beforeCreateServer), we will account for this limitation in Prisma by throwing an error when one of the entities' fields type is MultiSelectOptionSet. Here is an example of a use case where the plugin should throw an error:

beforeCreateServer(context: DsgContext, eventParams: CreateServerParams) {
    const generateErrorMessage = (
      entityName: string,
      fieldName: string
    ) => `MultiSelectOptionSet (list of primitives type) on entity: ${entityName}, field: ${fieldName}, is not supported by MySQL prisma provider. 
    You can select another data type or change your DB to PostgreSQL`;

    context.entities?.forEach(({ name: entityName, fields }) => {
      const field = fields.find(
        ({ dataType }) => dataType === EnumDataType.MultiSelectOptionSet
      );
      if (field) {
        context.logger.error(generateErrorMessage(entityName, field.name));
        throw new Error(generateErrorMessage(entityName, field.name));
      }
    });

    return eventParams;
  }

CreateServerDotEnv: before

On this event, we send our event parameters, specifically the environment variables for the MySQL database. As a result, the .env file will be generated with the default variables it already holds and our environment variables.

CreateServerDockerCompose: before

We also send our event params on this event, this time the YAML properties and values for the MySQL database. As a result, the docker-compse.yml file is generated so that the MySQL properties will replace the PostgreSQL properties.

CreateServerDockerComposeDB: before

This event is responsible for generating the docker-compose.db.yml file, which contains the docker image of the PostgreSQL database. Here we have an excellent example of skipDefaultBehavior usage. We can skip this file generation and provide different functionality—in this case, a separate file—later, in the after function.

beforeCreateServerDockerComposeDB(
    context: DsgContext,
    eventParams: CreateServerDockerComposeDBParams
  ) {
    context.utils.skipDefaultBehavior = true;
    return eventParams;
  }

CreateServerDockerComposeDB: after

After skipping the default behavior in the before function, we provide our docker-compose.db.yml file on this event.

async afterCreateServerDockerComposeDB(
    context: DsgContext
  ): Promise<ModuleMap> {
    const staticPath = resolve(__dirname, "./static");
    return await context.utils.importStaticModules(
      staticPath,
      context.serverDirectories.baseDirectory
    );
  }

CreatePrismaSchema: before

This event is responsible for manipulating the following part of the Prisma schema:

export const dataSource: DataSource = {
  name: "mysql",
  provider: DataSourceProvider.MySQL,
  url: {
    name: "DB_URL",
  },
};
EventParams

We use the event params to:

  • Change the data source name from Postgres to MySQL
  • Change the provider from Postgresql to MySQL

(CreateServerDotEnv handles the DB_URL)

Summary & Conclusion

Amplication's Plugin System allows developers to extend or alter the code generation process to include support for additional features or to make any changes to the default "flavor" of the generated application. It is an excellent way for our developer community to extend our platform safely and for our users to adjust the generated code to their specific needs. When writing these lines, over a dozen plugins are already available for Amplication, and our community and users are currently developing others.

In this blog, we reviewed what Plugins are and their architecture and gave a real-world example of creating a new Plugin.

If you liked the idea behind our Plugins and want to try writing one, please join our Discord community, where you can get help from fellow community members and our core team. We are friendly, and we like to help the community generate more useful plugins. If you develop a Plugin and we publish it, we will make sure to send you cool Swag 🙂.