5 Key Architectural Characteristics for Building Great Frontend Applications

5 Key Architectural Characteristics for Building Great Frontend Applications

·

5 min read

By Matheus Castiglioni

There are challenges to overcome when building new applications

Building new, frontend applications presents some key challenges that must be overcome to ensure they deliver what the user expects from them. They need the flexibility to add new features, the scalability to grow the project or add new developers to it, and the intuitive capability for developers to locate and implement fixes and incorporate new elements.

We can define a few architectural characteristics (-ilities) for these requirements, for example:

  • Scalability

  • Flexibility

  • Modularity

  • Readability

  • Simplicity

Each characteristic matches with a requirement discussed above. For instance, scalability matches with growing the project or bringing new developers to it. Flexibility, readability and simplicity match with adding new features or bug fixes. Modularity matches with giving semantic information to developers to know where they need to go and what are the software boundaries.

Now we’re able to start designing the project and architecture style.

Check out the react-example to see a simple demo project using the following principles and ideas addressed in the post.

Using a modular architecture

For scalability, flexibility and modularity, we can use a modular architecture to define the boundaries of the subdomains in the project. By using a modular architecture we have a domain-partitioned project structure where we can see the boundaries between each subdomain.

technical partitioned

domain partitioned

Here we can bring inspirations from domain-driven design, feature-based architecture, vertical slice architecture and screaming architecture (along with a range of other styles).

Adding logical layers

Next, still focusing on flexibility, we should put some logical layers into the project — each layer will carry a single responsibility and make the code evolution simpler. In this case, we’ll define these three layers:

  • Application

    • Containers

    • Interfaces

  • Infrastructure

  • Shared

Application layer

In the application layer (a.k.a subdomains) will reside all logic related to the application itself. For example:

  • Data management

  • API requests

  • UI components

  • All the pages for the application

  • Routes of the application

Etc…

application layer

project-name
  src
    subdomains
      products-catalog
        components
          // scoped components used only by products-catalog
          // subdomain
        product.entity.js
        routes.js
        services.js
        store.js
        ...

Container layer

The container nested layer is related to dependency injection. Its responsibility is to provide concrete implementations for the abstractions and each business capability should have a single container.

project-name
  src
    subdomains
      products-catalog
        containers // injecting dependencies
          list.container.js
          ...`

All the code examples will be written using React, as it’s the most popular frontend library as of now (you can use any other library or framework, though).

import { ApplicationLayout } from '@layouts'
import { PrivateRoute } from '@infrastructure'

import { ListInterface } from '../interfaces/list.interface'

export const ListContainer = () => {
  const i18n = useI18n()
  const router = useRouter()
  const store = useSelector()
  const features = useFeatureFlags()
  const client = useGraphQLClient()

  const askForData = () => {
    // retrieve all data from the API
    client.query()
  }

  const navigateTo = route => router.push(route)

  return (
    <PrivateRoute>
      <ApplicationLayout>
        <ListInterface
          features={features}
          messages={getMessages(i18n)}
          onNavigate={navigateTo}
          products={store.products.collection}
        />
      </ApplicationLayout>
   </PrivateRoute>
  )
}

Interface layer

The interface nested layer is related to defining the markup and the UI for the application pages — it’s where all the HTML (JSX/TSX) code goes. Each page needs to have a single container attached to it, as it’s a one-to-one relationship.

project-name
  src
    subdomains
      products-catalog
        interfaces // markup for all pages
          list.interface.js
          ...
export const ListInterface = ({
  features,
  messages,
  onNavigate,
  products,
}) => {
  const hasShoppingCart = features.includes(SHOPPING_CART')
  return (
    <>
      <h1>{messages.title}</h1>
      <Each collection={products}>
       {(product) => (
         <Card onClick={() => onNavigate(product.id)}>
           <Card.Title>{product.title}</Card.Title>
           <When condition={hasShoppingCart}>
             <Card.Action
               on={() => ...}
             >
               {messages.addToCart}
             </Card.Action>
           </When>
         </Card>
       )}
      </Each>
    </>
  )
}

Pay attention to the products-catalog/routes.js file, you have to import and attach only containers for the routes.

import { ListContainer } from './containers/list.container'

export const routes = {
  List: ListContainer,
}

Infrastructure layer

In the infrastructure layer, we’ll include all the necessary details on how abstractions should work and perform their actions. For example, handling API requests (using REST or GraphQL), store management, application theme logic, navigation handling and other things.

project-name
  src
    shared
      modules
        infrastructure
          fetch-data-provider.infrastructure.js
          ...
import { createClient, Provider } from 'urql'

export const FetchDataProvider = ({ children }) => {
  return (
    <Provider value={createClient()}>
      {children}
    </Provider>
  )
}

Shared layer

Finally, the shared layer will contain elements that are used across the entire application, usually global abstractions such as UI/business components, utils, global types and interfaces, helpers, translations, enums, layouts, and more.

project-name
  src
    shared
      modules
        components
          business // smart components with business logic
            price.component.js
          ui // dumb components only with interface logic
            button.component.js
        infrastructure
          fetch-data-provider.infrastructure.js
          state-provider.infrastructure.js
          theme-provider.infrastructure.js
        layouts
          admin.layout.js
          application.layout.js
        utils
          number.util.js
          text.util.js

We should create ADR (Architecture Decision Record) document files to specify the reason for each definition, but this is a topic for another blog post — in the meantime, if you want information on creating ADR document files, I recommend you read this article. Below we can see a landscape of all these requirements being resolved by each characteristic.

project-name
  src
    subdomains
      product-catalog
        containers
          list.container.js
        interfaces
          list.interface.js
        product.entity.js
        routes.js
        services.js
        store.js
    shared
      modules
        components
          business
            price.component.js
          ui
            button.component.js
        infrastructure
          fetch-data-provider.infrastructure.js
          state-provider.infrastructure.js
          theme-provider.infrastructure.js
        layouts
          admin.layout.js
          application.layout.js
        utils
          number.util.js
          text.util.js

Although the code examples were written using React, this architecture style is technology agnostic — by this I mean, libraries and frameworks. The style can be used by React, Angular, Vue, Svelte, Next, Nuxt, Gatsby, SvelteKit, Angular Universal, etc…

Conclusion

There’s no silver bullet to resolve all the problems and challenges we face when we’re building and shipping software. Instead, we need to focus on decreasing the complexity to better handle problems like the ones I’ve covered in this blog post.

By making the project semantic, using a nice folder structure with a separation of responsibilities, incorporating well-defined context boundaries, applying modularization and adding logic layers to handle different concerns we’re able to scale any software project.

References