Introduction
Dans le monde du développement de solution logicielle en entreprise, le cycle du développement a souvent été le suivant:
- Le métier (utilisateurs), le PM ainsi que l'UX/UI Designer échangent pour définir des concepts métier.
- l'UI/UX Designer concentre toutes ces informations et décisions dans des maquettes (via des outils tels que Figma).
- les maquettes sont présentées et challengées par les PMs et les utilisateurs.
Les étapes 2 et 3 peuvent se répéter un grand nombre de fois pour aboutir à des maquettes "aux petits oignons", représentant l'ébauche complète de la solution.
- Finalement, avec 2 mois de retard :
Les devs ont plusieurs semaines de contexte à rattraper, et vont commencer à développer sur une base d'un prototype énorme sans avoir pu challenger en amont la faisabilité et la pertinence de certains concepts. De plus, les développers ont la connaissance intime du produit technique. De nouvelles solutions, n'ayant pas été envisagées peuvent émerger des discussions et répondre parfaitement au besoin.
N'y aurait-il pas moyen de repenser un peu ce flow ?
Approche Frontend First
L'idée de cette approche pourrait se résumer comme ceci : obtenir le plus rapidement possible une solution interactive afin de valider les concepts avec les différentes parties prenantes.
Dès lors qu'une maquette minimaliste est créée par l'UX, celle-ci est envoyée aux développeurs. L'idée derrière cela est de raccourcir la boucle de feedbacks afin de mettre dans les mains des utilisateurs, le plus rapidement possible, un produit utilisable. Certes, toutes les fonctionnalités ne seront pas présentes, loin de là, mais cela constituera une base de discussion entre les équipes.
L'utilisateur pourra plus facilement faire des retours en utilisant une version (réduite certes) de la solution, qu'une maquette (ou une succession d'écrans fixes).
Les données présentées dans les vues proviennent de bouchons (via des implémentations in memory), nous rendant totalement indépendants de la partie backend, dont le développement peut avoir démarré, ou pas.
Il est toutefois primordial de se rendre compte que le code développé lors de ces itérations rapides coté frontend n'est pas du code jetable. Une fois la solution validée, les composants seront déjà développés et câblés à la solution finale.
Cette thématique a été également abordée par David Olivier (Staff Engineer chez Gojob) dans son podcast "Frontend-First" sur la chaine Artisan Développeur que je ne peux que vous conseiller, afin d'approfondir votre compréhension du fondement de cette approche.
Dans la suite de cet article, je vais vous présenter l'approche par l'exemple.
Un cas pratique
Admettons que vous souhaitiez créer une nouvelle application bancaire, avec le super nom GoCash. L'ensemble du code source présenté ici est disponible dans ce répertoire. Cet exemple se présente comme un projet suivant les concepts de la Clean architecture appliqués au Frontend. Vous pouvez trouver également ci-dessous quelques ressources sur ces sujets:
Les dossiers sont séparés comme suit :
- domain : Regroupe des entités avec lesquels nous allons travailler, définitions de nos gateways, et de notre store. L'ensemble de ce dossier doit être indépendant des frameworks et représente le centre de notre hexagone.
- ui : Ensemble des composants visuels de notre application.
- infrastructure : Comporte les adapteurs de notre application, qui peuvent ici être liés à des frameworks. Dans notre cas, il s'agira des implémentations des gateways définies dans le dossier domain.
Definition du domaine :
Entités :
Lors de la définition des entités de notre domaine, une des premières notions qui vient à l'esprit serait : "Qu'est-ce qu'un compte bancaire et comment le représenter?".
Nous pouvons imaginer l'entité suivante :
export type Account = {
id: string;
balance: number;
label: string;
}
Bon jusque-là, tout va bien. Un compte bancaire présente forcément des transactions, pouvant se matérialiser comme ceci :
export type Transaction = {
id: string;
amount: number;
label: string;
type: TransactionTypeEnum;
date: string;
accountId: string;
};
export enum TransactionTypeEnum {
INCOMING = "INCOMING",
OUTCOMING = "OUTCOMING",
}
Store :
Dans cet exemple, nous allons stocker nos données applicatives dans un store Redux en utilisant la librairie redux-toolkit.
Tout d'abord, il va falloir créer notre store. Celui-ci aura 2 dépendances :
AccountGateway
qui nous permettra de récupérer notre compteTransactionGateway
qui gérera les interactions de transactions
export type Dependencies = {
transactionGateway: TransactionGateway;
accountGateway: AccountGateway;
};
export const createStore = (
deps: Dependencies,
...extraMiddlewares: Middleware[]
) => {
return configureStore({
reducer: { transactions: transactionReducer, account: accountReducer },
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
thunk: {
extraArgument: deps,
},
}).concat(...extraMiddlewares),
});
};
export type GoCashStore = ReturnType<typeof createStore>;
export type GoCashGetState = GoCashStore["getState"];
export type GoCashState = ReturnType<GoCashGetState>;
export type GoCashDispatch = GoCashStore["dispatch"];
export type GoCashSelector<Result, Params extends unknown[] = unknown[]> =
Selector<GoCashState, Result, Params>;
export type GoCashThunk<ReturnType = Promise<void>> = ThunkAction<
ReturnType,
GoCashState,
Dependencies,
AnyAction
>;
account.gateway.ts
export interface AccountGateway {
fetchAccount(accountId: string): Promise<Account | undefined>;
}
transction.gateway.ts
import { Transaction } from "../transactions/transaction";
export interface TransactionGateway {
fetchTransactions(accountId: string): Promise<Transaction[]>;
}
Il est primordial de noter qu'ici, nous ne nous intéressons absolument pas à l'implémentation de notre gateway. Celle-ci sera gérée dans la couche d'infrastructure de notre application.
Slices :
Chacune de nos entités sera stockée dans un slice séparé. Un slice représente une sous-section du store Redux comme le montre la documentation de Redux Toolkit, et nous fournissent l'accès aux actions et reducers pour manipuler les données stockées. Ces slices se définissent comme ceci :
account.slice.ts
interface AccountState {
currentAccount: Account | null;
}
const initialState = { currentAccount: null } as AccountState;
const accountSlice = createSlice({
name: 'account',
initialState,
reducers: {
setAccount: (state, action) => {
state.currentAccount = action.payload;
},
},
});
export const selectAccountSlice = (state: GoCashState) => {
return state.account;
};
export const accountReducer = accountSlice.reducer;
export const accountActions = accountSlice.actions;
account.actions.ts
export const { setAccount } = accountActions;
account.selectors.ts
export const selectAccount = (state: GoCashState): Account | null => {
return selectAccountSlice(state).currentAccount;
};
À ce stade, nous sommes capables de récupérer des données d'un compte et de les stocker dans notre store.
Le slice des transactions sera présenté de manière un peu différente. S'agissant d'un tableau de transactions, nous allons utiliser la notion d'EntityAdapter
transaction.slice.ts
const transactionEntityAdapter = createEntityAdapter<Transaction>();
const transactionSlice = createSlice({
name: 'transactions',
initialState: transactionEntityAdapter.getInitialState(),
reducers: {
transactionsAddMany: transactionEntityAdapter.addMany,
transactionsRemoveAll: transactionEntityAdapter.removeAll,
},
});
export const selectTransactionsSlice = (state: GoCashState) => {
return state.transactions;
};
export const transactionSelectors = transactionEntityAdapter.getSelectors(selectTransactionsSlice);
export const transactionReducer = transactionSlice.reducer;
export const transactionActions = transactionSlice.actions;
Cas d'usages :
L'avant-dernière étape de setup de notre application est de réaliser les cas d'usages (use-cases). Il s'agit de chaque interaction unitaire que notre applicatif devra être capable de gérer. Les uses cases font l'interface entre notre couche domaine et infrastructure. Ici nous avons 2 uses cases clairement définis :
- récupérer les informations d'un compte
- récupérer la liste des transactions pour un compte donné.
Pour cela, nous allons créer des actions asynchrones qui vont venir interroger nos gateways afin de récupérer les informations (vous vous souvenez que nous n'avons toujours pas travaillé sur l'implémentation de nos gateways, c'est tout à fait normal, c'est la que la magie va opérer 🧙♂️🪄), puis propager les actions que nous avons définies dans nos slices pour venir remplir notre store.
fetch-account.use-case.ts
🪄
export const fetchAccount = (accountId: string): GoCashThunk => {
return async (dispatch, getState, { accountGateway }) => {
const account = await accountGateway.fetchAccount(accountId);
dispatch(setAccount(account));
};
};
fetch-transactions.use-case.ts
export const fetchTransactions = (accountId: string): GoCashThunk => {
return async (dispatch, getState, { transactionGateway }) => {
const transactions = await transactionGateway.fetchTransactions(accountId);
dispatch(transactionsRemoveAll());
dispatch(transactionsAddMany(transactions));
};
};
Definition de l'infrastructure :
Implémentation des gateways
Suivez-moi encore un petit peu, nous touchons au but 😅 Si nous récapitulons, nous pouvons récupérer des informations, les stocker, les récupérer ... c'est très bien... mais sans gateway, nous n'irons pas loin. Tout l'intérêt de l'approche frontend first est de s'affranchir de l'implémentation réelle de ces gateways. Peu importe la solution qui sera choisie par le projet pour réaliser les échanges de données entre Backend et Frontend, que ce soit Rest, GraphQL, JSON, ou autres moyens plus ou moins farfelus, nous allons être en capacité d'avancer et de proposer à notre équipe projet une première version utilisable de l'application.
Pour cela nous allons réaliser des implémentations en-mémoire
de ces gateways, qui seront chargées au chargement de la page:
stub-transaction.gateway.ts
export class StubTransactionGateway implements TransactionGateway {
public error: Error | null = null;
private transactionsStore: Map<string, Transaction>;
constructor(private delay: number = 0) {
this.transactionsStore = new Map();
}
private get transactions(): Transaction[] {
return [...this.transactionsStore.values()];
}
feedWithTransaction(transactions: Transaction[]) {
this.transactionsStore = new Map(transactions.map((transaction) => [transaction.id, transaction]));
}
async fetchTransactions(accountId: string): Promise<Transaction[]> {
await this.emulateServer();
return this.transactions.filter((transaction) => transaction.accountId === accountId);
}
private async emulateServer() {
await new Promise((resolve) => setTimeout(resolve, this.delay));
if (this.error) {
throw this.error;
}
}
}
Plusieurs choses à noter ici:
- la méthode
feedWithTransaction
permettra de charger notre stub avec les données que nous souhaitons utiliser par la suite - la méthode
emulateServer
permet de simuler la latence d'une communication réseau et pourra servir à tester des états de chargement. - Nous utilisons une map plutôt qu'un tableau pour pouvoir plus facilement effectuer une modification d'une transaction.
La même chose s'applique pour l'implémentation de stub-account.gateway.ts
Import du store et des gateways
Nous allons maintenant créer un fichier qui permettra de wrapper les composants React de notre application, qui permettra de créer les gateways ainsi que le store :
GoCashProvider.tsx
function createDefaultContext<T extends object>() {
const proxy = {} as T;
const handler = {
get: () => {
throw new Error("Please add GoCashStoreContext.");
},
};
return new Proxy(proxy, handler);
}
export interface GoCashContextProviders {
store: GoCashStore;
transactionGateway: TransactionGateway;
accountGateway: AccountGateway;
}
export const GoCashContext = createContext<GoCashContextProviders>(
createDefaultContext()
);
interface GoCashStoreProviderProps {
children: ReactNode;
}
const GoCashProvider = ({ children }: GoCashStoreProviderProps) => {
const [value] = useState<GoCashContextProviders>(() => {
const gateways = {
transactionGateway: new StubTransactionGateway(),
accountGateway: new StubAccountGateway(),
};
return {
...gateways,
store: createStore(gateways),
};
});
return (
<GoCashContext.Provider value={value}>{children}</GoCashContext.Provider>
);
};
export default GoCashProvider;
Une fois le projet avancé, il suffira de venir dans ce fichier remplacer les gateways avec les implémentations réelles (Rest, GraphQL, ...).
Réalisation de la page d'affichage du compte
Il est maintenant temps de nous attaquer à la couche d'affichage de notre application frontend. Pour cela nous allons afficher la page d'un compte bancaire avec la liste des transactions associées.
AccountView.tsx
type AccountViewProps = {
accountId: string;
};
const AccountView = ({ accountId }: AccountViewProps) => {
const dispatch = useDispatch();
const { isLoading } = useQuery(
["fetchAccount", accountId],
async () => await dispatch(fetchAccount(accountId)),
{
onSuccess: (data) => console.log(data),
onError: (data) => console.log(data),
}
);
const account = useGoCashSelector(selectAccount);
if (isLoading) {
return <LoadingAccount />;
}
if (!account) return null;
return (
<div className="w-[1000px] font-calibri flex flex-col justify-center items-center bg-white gap-4 p-4 m-4 shadow-xl">
<div className="flex h-24 flex-col gap-2 bg-blue-dark w-96 rounded-xl text-center justify-around items-center">
<div className="w-full text-white">{account.label}</div>
<div className="w-56 text-center text-green text-4xl">
{account.balance.toLocaleString()} €
</div>
</div>
<TransactionsView accountId={accountId} />
</div>
);
};
export default AccountView;
TransactionsView.tsx
type TransactionsViewProps = {
accountId: string;
};
const TransactionsView = ({ accountId }: TransactionsViewProps) => {
const dispatch = useDispatch();
const { isLoading } = useQuery(
["fetchTransactions", accountId],
async () => await dispatch(fetchTransactions(accountId)),
{
onSuccess: (data) => console.log(data),
onError: (data) => console.log(data),
}
);
const transactions = useGoCashSelector(selectTransactions);
if (isLoading) {
return <LoadingTransactions />;
}
return (
<div className="w-full flex-col gap-4">
{displayTransactionPerDate(transactions)}
</div>
);
};
const displayTransactionPerDate = (transactions: Transaction[]) => {
const map = new Map<string, Transaction[]>();
transactions.forEach((transaction) => {
const date = transaction.date;
if (!map.has(date)) {
map.set(date, [transaction]);
} else {
map.get(date)!.push(transaction);
}
});
let nodes: React.ReactNode[] = [];
map.forEach((transactions, date) => {
nodes.push(
<div key={date}>
<div className="w-full leading-8 bg-slate-200 px-4">{date}</div>
{transactions.map((transaction) => (
<TransactionLine transaction={transaction} key={transaction.id} />
))}
</div>
);
});
return nodes;
};
export default TransactionsView;
Nous pouvons voir ici que notre couche d'affichage se repose sur notre couche applicative, en dispatchant nos use-cases (ici fetchAccount
et fetchTransactions
) sans se soucier de l'implémentation qui en est faite.
Nous avons donc écrit du code de production qui sera parfaitement valide et réutilisable par la suite.
Validation de notre applicatif avec Storybook
export default {
title: "AccountView",
argTypes: {
loading: {
type: "boolean",
},
},
} as Meta;
interface Args {
loading?: boolean;
}
export const AccountViewStory: Story<Args> = ({ loading = false }) => {
const account: Account = makeAccount({
label: "Compte personnel de Mr John Doe",
balance: 1242.64,
});
const transactions = [
makeTransaction({
label: "Péage",
amount: 12.4,
accountId: account.id,
date: "2022-08-12",
}),
makeTransaction({
amount: 1534.23,
label: "Virement salaire de Juillet",
accountId: account.id,
date: "2022-08-12",
type: TransactionTypeEnum.INCOMING,
}),
...,
makeTransaction({
label: "Crédit immobilier",
accountId: account.id,
amount: 1104.76,
date: "2022-08-05",
}),
];
const transactionGateway = new StubTransactionGateway(200);
const accountGateway = new StubAccountGateway(200);
transactionGateway.feedWithTransaction(transactions);
accountGateway.feedWithAccount([account]);
const store = createStore({ transactionGateway, accountGateway });
const providers = { store, transactionGateway, accountGateway };
return (
<GoCashStoryProvider providers={providers}>
<div className="bg-slate-200 p-4 ">
<AccountView accountId={account.id} />
</div>
</GoCashStoryProvider>
);
};
Les principales limitations, selon notre vision sont :
- Cette approche est plus facilement applicable pour des applications internes que pour des produits B2C. En effet, la boucle de feedbacks avec des utilisateurs externes ne sera pas suffisamment rapide pour être pertinent.
- L'environnement de l'équipe de développement, et son appétence pour le frontend. En effet, si les outils, tels que la Clean Architecture, ne sont pas présents dans l'équipe et dans la code base, il sera plus difficile de rendre le code développé avec les bouchons utilisables avec des vraies sources de données.
- Cette approche est moins utile dans le cas où les développeurs ne sont pas fullstack. En effet, les connaissances acquises sur le système lors des itérations par l'équipe frontend doivent être transmises (potentiellement avec pertes) aux backends. Il est possible d'inclure des développeurs backend aux feedbacks mais cela complexifie l'organisation. Cependant, dans ce cas d'équipes backend et frontend séparées, l'approche de Clean architecture permet aux développeurs backend et frontend d'avancer en parallèle. Les frontend n'ont qu'à enlever les bouchons, et câbler sur les réelles sources de donnés.
Conlusion
Nous avons vu, par cet exemple, la puissance de l'approche frontend-first et ses avantages dans la gestion d'un projet:
- Toute l'equipe (UX/PM/dev/utilisateurs finaux) avance au même rythme et converge vers une solution plus rapidement et de façon plus pérenne.
- Le code développé lors des itérations est directement du code de production.
- Des solutions techniques peuvent émerger des discussions par la participation des développeurs avec leur forte connaissance du produit d'un point de vue technique.
- L'architecture qui en résulte est propre (séparation des implémentations des gateways, cas d'usage, découplage entre le code domaine et les frameworks, la capacité à tester le domaine unitairement ...)
Si vous n'aviez pas arrêté de lire cet article pour vous jeter sur le podcast de David, c'est le moment de vous y mettre ! Je vous redonne également le lien vers le dossier contenant le code de l'exemple.