Scalable Frontend Architecture That Meets Your Business

A presentation at EmberFest in September 2024 in Dublin, Ireland by Thomas Gossmann

Slide 1

Slide 1

Scalable Frontend Architecture that meets Your Business Thomas Gossmann - gos.si - @unistyler

Slide 2

Slide 2

Architecture Architects: Draw the map and guide engineers to the treasure Engineers: Read the map to reach the treasure

Slide 3

Slide 3

Quiz: What does this Product do? (1)

Slide 4

Slide 4

Quiz: What does this Product do? (2)

Slide 5

Slide 5

Quiz: What does this Product do? (3)

Slide 6

Slide 6

Default Directory Structure - Why ? Good onboarding to the framework Explains technical aspects of the framework Good for hobby and weekend projects Hardly scalable beyond that

Slide 7

Slide 7

PART 1 Meet Your Business Tactical Design

Slide 8

Slide 8

Technical Objects Domain Objects Components Contract Services Appointment Routes Risk Audit not aspects of your product Saloon Calendar they are aspects of your product

Slide 9

Slide 9

Why there is no Domain-Driven Development ? It is hard to do. Some observed reasons: 1. Education: Data Structures, Algorithms, Design Patterns, Performance, … Missing: Linguistic Course, Domain-Driven Design Pratices 2. We design development workflows for technical aspects 3. No visibility for the domain in our code Lack of feedback from product people or designers No reward to engineers for their contributing impact

Slide 10

Slide 10

Can we (Re)Design our Development Workflow with the Business in Mind?

Slide 11

Slide 11

  1. Identify Technical Aspects that Encode Business Logic

Slide 12

Slide 12

Queries Commands function query(…args: unknown[]): NonNullable<unknown>; function command(…args: unknown[]): void; Read Write Questions: Ask facts about the system Fire & Forget Abilities/Authorization/Guards/Conditions/Criteria: May/should cause side effects Control acces Command-Query-Separation (CQS) Functions to either be commands that perform an action or queries that respond data, but neither both!

Slide 13

Slide 13

Queries: Presentation Logic / Control Flow Two Times Business Logic. Two Times Anti-Patterns Helper {{#if (feature-flag ‘PROPLUS’)}} Special Feature here {{/if}} Components import Component from ‘@glimmmer/component’; import { service } from ‘@ember/service’; import type FeaturesService from ‘whereever/features-infra-sits’; class Search extends Component { @service declare features: FeaturesService; get isProPlus() { return this.features.has(‘PROPLUS’); } What’s the name of the feature? <template> {{#if this.isPropPlus}} hint: it is not “Pro Plus”, that’s only the feature flag currently Special Feature here {{/if}} </template> used for its condition Not unit testable :( }

Slide 14

Slide 14

Queries: Data Fetching Fetching data from your API Business logic part: Endpoint Parameters Payload structure

Slide 15

Slide 15

Commands: Actions Components import Component from ‘@glimmer/component’; import { action } from ‘@ember/object’; import { AnotherComponent } from ‘your-ui’; class Expose extends Component { @action onClick() { // whatever happens here Services import Service from ‘@ember/service’; class UserService extends Service { createUser(data) { // … } deleteUser(userId: number) { } // … } <template> <AnotherComponent @onClick={{this.onClick}}> Something sits here </AnotherComponent> </template> } }

Slide 16

Slide 16

Services Services is an overloaded Term Infrastructure Services Application Services Domain Services API client Session Domain Objects (CRUD) Messaging / Message Broker Features e.g. UsersService A/B Testing

Slide 17

Slide 17

We host Business logic in Components, Services, Routes, Controllers, Models merely to use Ember’s DI system. We created a strong coupling of business logic to Ember’s DI system 🤔

Slide 18

Slide 18

What is the correct Statement? (A) Make a Framework a Dependency of your Business? (B) Your Business drives Implementation within a Framework?

Slide 19

Slide 19

  1. (Re)Design our Development Workflow

Slide 20

Slide 20

Rideshare Example On the Development of Reactive Systems with Ember.js Domain Modeling Made Functional Domain Modeling M… M… On the Development… Development… by Scott Wlaschin by Clemens Müller and Michael Klein

Slide 21

Slide 21

interface User { id: string; name: string; type: ‘rider’ | ‘driver’; } type RideState = | ‘requested’ | ‘declined’ | ‘awaiting_pickup’ | ‘driving’ | ‘arrived’ | ‘payed’ | ‘canceled’; interface Ride { id: string; from: string; to: string; riderId: string; driverId: string; state: RideState; }

Slide 22

Slide 22

interface User { id: string; name: string; type: ‘rider’ | ‘driver’; } // actions function request(ride: Ride, rider: User): void; function accept(ride: Ride, driver: User): void; function drive(ride: Ride, driver: User): void; function arrive(ride: Ride, driver: User): void; function pay(ride: Ride, rider: User): void; type RideState = | ‘requested’ | ‘declined’ | ‘awaiting_pickup’ | ‘driving’ | ‘arrived’ | ‘payed’ | ‘canceled’; function cancel(ride: Ride, user?: User): void; interface Ride { id: string; from: string; } // guards rsp. abilities function canRequest(ride: Ride, user: User): boolean; function canAccept(ride: Ride, user: User): boolean; function canDrive(ride: Ride, user: User): boolean; function canDecline(ride: Ride, user: User): boolean; function canArrive(ride: Ride, user: User): boolean; function mustPay(ride: Ride, rider: User): boolean; // questions function isDriver(user: User): boolean; to: string; riderId: string; function isRider(user: User): boolean; function isDriverFor(ride: Ride, driver: User): boolean; driverId: string; state: RideState; function calculateTravelDistance(ride: Ride): number;

Slide 23

Slide 23

Implementation Goal Ride Details Page Task Based UI Domain Code in plain TS Thin layer in Ember for DI integration import { canAccept, mustPay, accept, pay } from ‘your-domain’; import { Button } from ‘@hokulea/ember’; import type { TOC } from ‘@ember/component/template-only’; import type { Ride } from ‘ember-domain’; interface RideActionsSignature { Args: { ride: Ride; } } Given User is given as part of SessionService APIClient is our APIService const RideActions: TOC<RideActionsSignature> = <template> {{#if (canAccept @ride)}} <Button @push={{fn (accept) @ride}}>Accept</Button> {{/if}} {{#if (mustPay @ride)}} <Button @push={{fn (pay) @ride}}>Pay</Button> {{/if}} </template> export { RideActions };

Slide 24

Slide 24

2.1. Actions 1. Bi-Directional API, Statechart, Event-Driven Architecture, CQRS/ES 2. Uni-Directional API, Statechart, CRUD 3. Uni-Directional API, CRUD

Slide 25

Slide 25

Implementing Scenario 1 Fire & Forget import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept, { driverId: driver.id }); } Implementation to focus on: Additionally to the Domain Endpoint Infrastructure/technically relevant parameters Parameters Develop against interfaces Payload Structure Perfect to mock for testing

Slide 26

Slide 26

Scenario 1: Setup

Slide 27

Slide 27

Scenario 1: Action Resource Statechart BE MessageBroker Consumer call accept POST /ride/:id/accept publishEvent(‘accept’) notifyAboutEvent(‘accept’) forward the Event updates Ride Updates UI Resource Consumer Statechart accept BE MessageBroker

Slide 28

Slide 28

Implementing Scenario 2 Fire & Play BE in FE import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept, { driverId: driver.id }); }

Slide 29

Slide 29

Scenario 2: Setup

Slide 30

Slide 30

Secnarion 2: Action

Slide 31

Slide 31

Implementing Scenario 3 Fire & Play BE in FE import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient }: { apiClient: APIClient }): void { await apiClient.post(/ride/${ride.id}/accept, { driverId: driver.id }); }

Slide 32

Slide 32

2.2. Abilities function canAccept(ride: Ride, user: User) { // when… return ( // ride is in state requested… ride.state === RideState.Requested && // AND user is a driver isDriver(user) ); } use single exit functions no guards with early exits, we are only interested when something can be done, not when it can’t be done readability: use positive statements (non negated statements) annotate with comments to explain tricky nonreadable code for non-tech people (when necessary)

Slide 33

Slide 33

2.3. Integration with Ember

Slide 34

Slide 34

Abilities function canAccept(ride: Ride, user: User) { return ride.state === ‘requested’ && isDriver(user); } Actions import type { APIClient } from ‘infra’; async function accept(ride: Ride, driver: User, { apiClient await apiClient.post(/ride/${ride.id}/accept, { driverId: driver.id }); } {{#if (canAccept @ride)}} … {{/if}} <Button @push={{fn (accept) @ride}}>Accept</Button>

Slide 35

Slide 35

Abilities: ability() from ember-ability import { canAccept as upstreamCanAccept } from ‘your-plain-ts-domain’; import { ability } from ‘ember-ability’; const canAccept = ability((owner) => (ride: Ride) => { const session = owner.lookup(‘service:session’); const { user } = session; ember-sweet-owner return upstreamCanAccept(ride, user); }); import { sweetenOwner } from ‘ember-sweet-owner’; export { canAccept }; const { services } = sweetenOwner(owner); const { session } = services; {{#if (canAccept @ride)}} … {{/if}}

Slide 36

Slide 36

Actions: action() from ember-command import { accept as upstreamAccept } from ‘your-plain-ts-domain’; import { action } from ‘ember-command’; const accept = action(({ services }) => (ride: Ride) => { const { session, api } = services; const { user } = session; upstreamAccept(ride, user, { apiClient: api }); }); export { canAccept }; <Button @push={{fn (accept) @ride}}>Accept</Button>

Slide 37

Slide 37

Domain Code is actually tiny many tiny functions easy unit testing Plain TS can be integrated into multiple systems: thin integration layer into frameworks statecharts but: is still hard to write code like that that’s a naive design needs visibility a way to reward engineers

Slide 38

Slide 38

Finish the Development Workflow Design can we have a “magic number” (similar to code-coverage), that signals: “good code quality that follows our architecture design” I haven’t found one… (yet?) Follow nature: Indicator Species Bridge between engineers and non-tech-people Use: typedoc

Slide 39

Slide 39

Slide 40

Slide 40

Configure typedoc Organize our domain aspects: /** * @group Domain Objects * @module Ride / Give meaning to our code: /* * @category Abilities * @source */ Plugin: typedoc-plugin-inline-sources Configure typedoc: “navigation”: { “includeCategories”: true, “includeGroups”: true, “includeFolders”: false }, “categorizeByGroup”: false

Slide 41

Slide 41

Slide 42

Slide 42

Benefits Make complexity visible Significant reduction in bugs Feature devlivery improved by factor 2-3x Increased developer velocity Business logic Lego

Slide 43

Slide 43

PART 2 Organizing Code and Scale it Up Strategic Design

Slide 44

Slide 44

Naive Approach Use Ember Addons Use Ember Engines Move things from app into addons/engines “False” Scalability

Slide 45

Slide 45

Example: A Zoo The technical goal is to keep animals and visitors separated Technical Let’s make a compound for animals and a compound for visitors Missing accomplished Domain Who put herbivores and carnivores in the same compound ? Short term attraction No long term, sustainable solution Frontend Architecture: How to Bu… Bu…

Slide 46

Slide 46

Domain Understanding subdomains Core Subdomain Unique/Core part of your product. Supporting Subdomain Ancillary parts that support your core. Generic Subdomain We’ll find these parts in many applications (e.g. user management). Subdomains help you distill your product into manageable pieces.

Slide 47

Slide 47

Time to Solve that Puzzle

Slide 48

Slide 48

Slide 49

Slide 49

Slide 50

Slide 50

github.com/gossi/unidancing

Slide 51

Slide 51

Colophon UniDancing.art Each domain directory has an index.gts which contains the public API Routes are exported as part of each domains public API // routes/exercises/index.gts export { IndexRoute as default } from ‘../../domain/core/exercises’; ember-polaris-routing : for defining routes (there is also ember-route-template ) ember-polaris-service : Infrastructure located in their respective domain (no root level services/ directory)

Slide 52

Slide 52

What’s Inside a Subdomain? Domain Objects Actions Abilities Questions Components Routes Services / Resources Public API as gateway to export what is accessible from the outside

Slide 53

Slide 53

Monolith Modular Monolith

Slide 54

Slide 54

Modular Monolith 1. Directory: domain/ 2. Monorepo: Private and public packages packages per subdomain 3. Polyrepo: One repository per subdomain with private and public packages Scaling Up Modular Monolith Directory Monorepo Monolith Polyrepo Microfrontend

Slide 55

Slide 55

Modular Monolith: Polyrepo One repo per subdomain Pro @unidancing/training Use the physical boundaries of a repo for core internal/public API public-api Everything public API is published to your registry ember-core (addon) ember (addon) main (engine) Contra You need the publish/update dance Use release-plan Use renovate / dependabot to automate updates Tip Legend: Internal Public

Slide 56

Slide 56

Modular Monolith: Monorepo One repo for all subdomains Pro domain/core/ No need to for publishing/updating choreography/ Faster development time training/ core ember (addon) Contra Needs to mimic the boundaries of a polyrepo exercises/ Linting is required! Extra tooling for linting against internal/public APIs Tip Legend: Internal Public

Slide 57

Slide 57

Microfrontend Subdomain independently deployable Ember engines would be the technological choice Currently not possible ember-engines Use them for isolated context Do NOT use them for route/chunk splitting (use embroider for that) Similar to “composable components”, Ember will have “composable apps” - and I think that is beatiful The technical solution for this is unclear as of now (apps and engines might merge)

Slide 58

Slide 58

Takeaways Focus on the domain Make your domain/complexity visible Reward your engineers for their contribution impact Your domain tells you how to scale up

Slide 59

Slide 59

Thank You :) Thomas Gossmann - gos.si - @unistyler