Project Structure

In this lesson, we'll get familiar with our Adonis 5 project by running through all the folders and files and their purposes. We'll learn which folders and files within our project structure

Published
Aug 14, 21
Duration
12m 5s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Now that we have our Adonis application created and we've verified that it runs okay, let's go ahead and start to dive into our project and learn the actual project structure.

So, go ahead and open up your newly created project in your text editor of choice, I'm using Visual Studio Code, and off to the left-hand side, you can see that we're started with a number of directories and files. Some of these you might be familiar with, if not that's okay we're going to spend some time in this lesson going through each of these different folders and files so that you know exactly where you are within your project structure at all times.

To start, there are going to be two directories we're going to spend the vast majority of our time within. The app directory and the resources directory.

App Directory

The app directory is going to contain almost, if not all, of our business logic. It's going to contain our controllers, models, services, exceptions, middleware, and so on.

Each of those is going to get its own subdirectory within the app directory. Adonis actually starts us off with an Exceptions directory, which contains a single file called Handler.ts. This file is in charge of handling our application's exceptions during an HTTP Request.

By default, the Handler.ts file looks like this:

// app/Exceptions/Handler.ts

/*
|--------------------------------------------------------------------------
| Http Exception Handler
|--------------------------------------------------------------------------
|
| AdonisJs will forward all exceptions occurred during an HTTP request to
| the following class. You can learn more about exception handling by
| reading docs.
|
| The exception handler extends a base `HttpExceptionHandler` which is not
| mandatory, however it can do lot of heavy lifting to handle the errors
| properly.
|
*/

import Logger from '@ioc:Adonis/Core/Logger'
import HttpExceptionHandler from '@ioc:Adonis/Core/HttpExceptionHandler'

export default class ExceptionHandler extends HttpExceptionHandler {
  protected statusPages = {
    '403': 'errors/unauthorized',
    '404': 'errors/not-found',
    '500..599': 'errors/server-error',
  }

  constructor () {
    super(Logger)
  }
}

Adonis starts us off with a couple of defined status pages, which are used to handle our exceptions when thrown in a production environment. When we're in development, Adonis will utilize a package called Youch to display useful information about the exception that was thrown to assist us in debugging our application.

So, in production, when an exception is thrown if the exception status matches one of these defined statusPages statuses, Adonis will search for the view defined as the value and render that page off to notify the user about the error. If a 403 exception is thrown, Adonis will render the view at resources/views/errors/unauthorized.edge. For a 404, Adonis will render resources/views/errors/not-found.edge. And, for a 500 through a 599, Adonis will render resources/views/errors/server-error.edge. Adonis also starts us off with these error pages within our resources/views/errors directory.

Resources Directory

The resources directory is going to contain all of our uncompiled front-end assets. So, this is going to contain our JavaScript, Cascading Style Sheet (CSS), and view files. It can also contain raw images if you need to process images prior to them being made available publicly to users, and any other assets our front-end will need to have processed.

When we're talking about pages, rendering pages, or rendering views (like with our exception handler), Adonis is going to default to looking within our resources/views directory for those files. Now, you might've noticed that these files end with a .edge extension. If we take a look at our welcome.edge file, which is the "It Works!" page we saw when we started our server up, you'll see the following.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>AdonisJS - A fully featured web framework for Node.js</title>
  @entryPointStyles('app')
  @entryPointScripts('app')
</head>
<body>

  <main>
    <div>
      <h1 class="title"> It Works! </h1>
      <p class="subtitle">
        Congratulations, you have just created your first AdonisJS app.
      </p>

      <ul>
        <li>
          The route for this page is defined inside <code>start/routes.ts file
        </li>

        <li>
          You can update this page by editing <code>resources/views/welcome.edge file
        </li>

        <li>
          If you run into problems, you can reach us on <a href="https://discord.gg/vDcEjq6?">Discord</a> or the <a href="<https://forum.adonisjs.com/>">Forum</a>.
        </li>
      </ul>
    </div>
  </main>
</body>
</html></code>

At first glance, it looks like nothing more than standard Hypertext Markup Language (HTML), however, it's a full-blown template engine. It supports conditionals, loops, partials, components, layouts, local variables, and of course injecting data directly into the page.

Edge also isn't tied specifically to HTML, you can utilize it to render out other types of files as well, like TXT, PDF, XML, and so on. We can also utilize it to generate our email markup.

Edge and Text Editors
Now, by default, your text editor likely doesn't support Edge. However, for Visual Studio Code, Sublime Text, Atom, and Vim there's a plugin that'll add Edge support.

For an up-to-date listing, you can refer to this section within the documentation.

Public Directory

The public directory is for compiled, processed, and static assets that are ready-to-go for our users to view. So, for our uncompiled JavaScript and CSS files within our resources directory, the public directory will contain the compiled versions of those assets. The compiled versions are then what our application is going to use.

The @entryPointStyles('app') and @entryPointScripts('app') you see within our welcome.edge file are actually Edge helpers that inject a <link> element for our compiled CSS. The 'app' argument provided specifies to specifically look for a compiles CSS file called app.css. The same applies to our JavaScript, it'll inject a <script> element for our compiles JavaScript.

The reason these two helpers are so beneficial over just adding the elements and sources ourselves is that these helpers will add a version suffix to the sources with a hashed version of the files. The version suffix is then changed anytime these files change. So, in production when this happens the cached version of the files will be skipped for our users and the users will then get the newly updated version. So this helps ensure our users are using the latest versions of our asset files.

Commands Directory

Adonis comes with its own Command-Line Interface (CLI), called Ace CLI. You can actually create your own commands for usage with Ace CLI as well. These custom commands are what the commands directory is for. It'll house all of our custom commands specific to our application.

Adonis starts us off with an index.ts file within the commands directory. This file is in charge of reading any custom commands we add to this directory and providing those commands back to the Ace CLI.

import { listDirectoryFiles } from '@adonisjs/core/build/standalone'
import Application from '@ioc:Adonis/Core/Application'

/*
|--------------------------------------------------------------------------
| Exporting an array of commands
|--------------------------------------------------------------------------
|
| Instead of manually exporting each file from this directory, we use the
| helper `listDirectoryFiles` to recursively collect and export an array
| of filenames.
|
| Couple of things to note:
|
| 1. The file path must be relative from the project root and not this directory.
| 2. We must ignore this file to avoid getting into an infinite loop
|
*/
export default listDirectoryFiles(__dirname, Application.appRoot, ['./commands/index'])

Config Directory

The config directory houses all of our application's configurations. These are conveniently separated into different files, grouped by the config topic.

We have a specific config for our

  • App

  • Body Parser

  • CORS

  • Hashing

  • Session

  • Security called Shield

  • Static

As we add more Adonis modules to our application, more configuration files will be added as well. For a complete reference of first-party Adonis configs please refer to this documentation page.

You can also edit these as needed and add new configuration files as needed as well. These will then be easily usable throughout our application like so:

import sessionConfig from 'Config/session'

sessionConfig.cookieName

Contracts Directory & Environment Variables

Throughout Adonis, you'll be seeing the term contracts a lot. Now, I'll admit I'm not a TypeScript wiz, I don't heavily utilize it. With that caveat there, my understanding of contracts is that it's essentially strict TypeScript type definitions.

Adonis starts us off with a couple of contracts within our contracts directory. If we take a look at our contracts/env.ts file, we'll see the following.

/**
 * Contract source: <https://git.io/JTm6U>
 *
 * Feel free to let us know via PR, if you find something broken in this contract
 * file.
 */

declare module '@ioc:Adonis/Core/Env' {
  /*
  |--------------------------------------------------------------------------
  | Getting types for validated environment variables
  |--------------------------------------------------------------------------
  |
	| The `default` export from the "../env.ts" file exports types for the
	| validated environment variables. Here we merge them with the `EnvTypes`
	| interface so that you can enjoy intellisense when using the "Env"
	| module.
  |
  */

	type CustomTypes = typeof import("../env").default;
  interface EnvTypes extends CustomTypes {
  }
}

So, this is declaring the module @ioc:Adonis/Core/Env, which we can import throughout our project and have access to our environment variables. Inside the module declaration, it's importing CustomTypes from our env.ts file. This env.ts file looks like this:

/*
|--------------------------------------------------------------------------
| Validating Environment Variables
|--------------------------------------------------------------------------
|
| In this file we define the rules for validating environment variables.
| By performing validation we ensure that your application is running in
| a stable environment with correct configuration values.
|
| This file is read automatically by the framework during the boot lifecycle
| and hence do not rename or move this file to a different location.
|
*/

import Env from '@ioc:Adonis/Core/Env'

export default Env.rules({
	HOST: Env.schema.string({ format: 'host' }),
	PORT: Env.schema.number(),
	APP_KEY: Env.schema.string(),
	APP_NAME: Env.schema.string(),
	CACHE_VIEWS: Env.schema.boolean(),
	SESSION_DRIVER: Env.schema.string(),
	NODE_ENV: Env.schema.enum(['development', 'production', 'testing'] as const),
})

This file is utilized to provide IntelliSense and define required environment variables, which the actual environment variables reside within our .env file. These are secure variables that shouldn't be made publicly available. Using the env.ts file we're defining which keys must be within our .env file and what those key types need to be, and optionally, further restrictions on the file.

For example, our NODE_ENV type is specifying that the NODE_ENV value within our .env is required and the value needs to be either development, production, or testing. Nothing else is allowed otherwise our server will refuse to start.

These types are being enforced by an EnvContract. You can inspect the full Env declaration here, I'm just going to pluck out specifically the EnvContract from here so we can inspect it.

declare module '@ioc:Adonis/Core/Env' {
	// ... other stuff

  /**
   * Env contract
   */
  export interface EnvContract {
    /**
     * Get value for a given environment variable
     */
    get<K extends keyof EnvTypes>(key: K): EnvTypes[K]
    get<K extends keyof EnvTypes>(
      key: K,
      defaultValue: Exclude<EnvTypes[K], undefined>
    ): Exclude<EnvTypes[K], undefined>
    get(key: string, defaultValue?: any): any

    /**
     * Update/set value for a given environment variable. Ideally one should
     * avoid updating values during runtime
     */
    set<K extends keyof EnvTypes>(key: K, value: EnvTypes[K]): void
    set(key: string, value: any): void

    /**
     * Validate environment variables
     */
    rules<T extends { [key: string]: ValidateFn<unknown> }>(
      values: T
    ): {
      [K in keyof T]: ReturnType<T[K]>
    }

    /**
     * Processes environment variables and performs the registered
     * validations
     */
    process(): void

    /**
     * Reference to the schema object for defining validation
     * rules
     */
    schema: EnvSchema
  }

  const Env: EnvContract
  export default Env
}

So, EnvContract is specifically defining the methods and properties that'll be available within Env when we import Env from '@ioc:Adonis/Core/Env'. Furthermore, it's also providing validations on those types, making these types strict.

Please, take my understanding of contracts with a grain of salt, I'm probably not the best person to explain contracts because I'm not a heavy TypeScript user. This README from GitHub user bigslycat is where I gained my understanding from.

Providers Directory

The providers directory contains, by default, an AppProvider. Providers have many usages, from providing additional global variables or functions to our views to extending Adonis with custom packages or third-party packages.

Providers are loaded once during our application's boot cycle and provide us access to run code at particular lifecycle hooks of our application.

The AppProvider we're started with looks like the below.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

export default class AppProvider {
  constructor (protected app: ApplicationContract) {
  }

  public register () {
    // Register your own bindings
  }

  public async boot () {
    // IoC container is ready
  }

  public async ready () {
    // App is ready
  }

  public async shutdown () {
    // Cleanup, since app is going down
  }
}

So, as you can see providers are classes, which are provided an instance of our application's ApplicationContract, which contains things like our logger, environment variables, configurations, helpers, node env, etc.

Within our boot lifecycle hook we can also import additional modules we may need to extend, like the View module.

You can add as many provider files as you need, just note as you add additional providers within your application, these providers will also need to be registered within your adonisrc.json file.

Start Directory

The start directory contains items that are defined only once within our application's boot lifecycle hook. This is going to be things like our routes, middleware registration, events, etc.

By default, Adonis starts us off with a routes.ts and kernel.ts file. routes.ts is where we'll register our routes for our application. kernel.ts is where we'll register our application's middleware.

You can add as many files as you need to your start directory, just note they'll be executed once when your application is booted. Any additional files you create will also need to be registered within the preloads array in your adonisrc.json file.

adonisrc.json

The adonisrc.json file is used to register files and configure our Adonis application. This is where you'll need to add any additional providers and start files you add to your project in order for Adonis to pick them up.

If you're going to alter anything within the adonisrc.json file that isn't within the preloads or providers array, I'd recommend you check out the documentation for what you're altering to better understand what you're doing.

server.ts

This is essentially where our application server is bootstrapped. Unless you're doing some more advanced stuff you're most likely never going to need to touch this file.

tsconfig.json

This file is specifically for configuring TypeScript within our application. You'll most likely only touch this file if you need to exclude TypeScript from reading particular files or directories if you need to define additional paths or types for the TypeScript compiler. If you change any aliases within your adonisrc.json file, those will also need to be updated within the paths object of your tsconfig.json file.

webpack.config.js

This is only included if you selected to include Webpack Encore within your application. This is where we're going to specify what resource assets we want to compile and how those files should be compiled. When we start up our server these files will then be compiled out and dropped where we specify, which should be our public directory.

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!

Playing Next Lesson In
seconds