DEV Community

Cover image for La collaboration : fondement de l'architecture logicielle
Nicolas Gauthier for Onepoint

Posted on

La collaboration : fondement de l'architecture logicielle

La conception d'un système logiciel repose avant tout sur la compréhension fine des interactions entre ses différents éléments constitutifs. Qu'il s'agisse de systèmes distribués, de modules au sein d'une application ou d'objets dans un paradigme orienté objet, la collaboration représente le cœur même de l'architecture logicielle.

La collaboration comme pierre angulaire

Les systèmes logiciels sont constitués d'éléments plus ou moins complexes qui communiquent entre eux, échangent des données et coordonnent leurs actions pour accomplir des objectifs. Cette collaboration se manifeste à travers différents mécanismes :

  • appels de méthodes,
  • échanges de messages,
  • partage de ressources,
  • synchronisation d'états
  • ou encore notification d'événements.

La qualité de ces interactions détermine directement les propriétés émergentes du système final :

  • sa robustesse,
  • sa maintenabilité,
  • ses performances
  • et sa capacité d'évolution.

Une collaboration mal conçue peut transformer un ensemble de composants individuellement fonctionnels en un système fragile et imprévisible.

L'essence de la stabilité et de la volatilité

Avant d'aborder toute question architecturale, il est crucial de caractériser la nature des collaborations selon leur degré de stabilité. Cette analyse permet de distinguer deux catégories fondamentales d'interactions.

Collaborations stables

Les collaborations stables correspondent aux interactions dont le comportement est prévisible et déterministe. Un collaborateur stable est celui dont les réactions peuvent être anticipées et maîtrisées par le système qui l'utilise. Il répond de manière cohérente aux mêmes sollicitations et ses comportements restent dans le périmètre de contrôle du système.

⚙️ Cas d'usage

Prenons l'exemple suivant, permettant à un système de transférer une somme d'argent entre deux comptes :

public class TransferMoneyUseCase {

    public void transfer(Account issuer, Account receiver, BigDecimal amount) {
        issuer.withdraw(amount);
        receiver.deposit(amount);
    }

}
Enter fullscreen mode Exit fullscreen mode
public class Account {
    private final UUID id;
    private BigDecimal balance;

    public Account(UUID id, BigDecimal balance) {
        this.id = id;
        this.balance = balance;
    }

    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalAmountException();

        if (balance.compareTo(amount) < 0)
            throw new InsufficientFundsException();

        balance = balance.subtract(amount);
    }

    public void deposit(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) < 0)
            throw new IllegalAmountException();

        balance = balance.add(amount);
    }

    public BigDecimal getBalance() {
        return balance;
    }

    public UUID getId() {
        return id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cette opération de transfert est réalisée par la classe TransferMoneyUseCase, laquelle collabore (par l'intermédiaire de sa méthode transfer) avec les objets Account et BigDecimal pour effectuer la transaction.

Intéressons-nous particulièrement au collaborateur Account. Comment peut-on qualifier les intéractions initiées par TransferMoneyUseCase ?

➡️ Prévisibles et déterministes

L’exécution des méthodes withdraw et deposit sur les instances d'Account donne toujours le même résultat pour un même contexte d'appel. Les cas limites sont clairement prévisibles et gérés par des exceptions métier explicites (IllegalAmountException, InsufficientFundsException).

➡️ Contrôlable par le système

La méthode transfer interagit directement avec une API publique maîtrisée (withdraw, deposit), sur des objets Account contrôlables à travers de :

  • leur état initial (le solde avant toute transaction),
  • leurs mécanismes d'interaction (les méthodes exposées),
  • leur état observable (le solde courant).

➡️ Sans interférence

Aucun effet secondaire non maîtrisé ne vient interférer lors de l'exécution : pas d'accès réseau, pas de base de données, pas d'élément asynchrone ou aléatoire. La classe Account encapsule les règles métier et reste indépendante de tout composant externe incontrôlable et imprévisible.

Collaborations volatiles

À l'opposé, les collaborations volatiles se caractérisent par leur imprévisibilité et échappent au contrôle du système. Elles concernent généralement les aspects périphériques, les intégrations avec des services externes, la gestion de l'aléatoire, la temporalité, etc. Leur comportement ne peut être ni anticipé ni maîtrisé par le système qui les utilise. Ces zones d'intéraction nécessitent alors une approche architecturale différente.

⚙️ Cas d'usage

Poursuivons avec notre cas d'usage précédent. Les opérations effectuées par le système lors du transfert nécessitent maintenant d'être sauvegardées dans un système externe (une base de données par exemple). Ce système, par sa nature, échappe au contrôle du système appelant. La collaboration devient alors non maitrisée.

public class TransferMoneyUseCase {
    private final AccountRepository accountRepository;

    public TransferMoneyUseCase() {
        this.accountRepository = new AccountRepository();
    }

    public void transfer(Account issuer, Account receiver, BigDecimal amount) {
        issuer.withdraw(amount);
        receiver.deposit(amount);

        accountRepository.save(issuer);
        accountRepository.save(receiver);
    }

}
Enter fullscreen mode Exit fullscreen mode

Dans cet exemple, TransferMoneyUseCase dépend désormais d’un composant externe, AccountRepository, en charge de la persistence des comptes. Or, la base de données utilisée par ce repository n’est pas contrôlée par le système, ce qui introduit des effets de bord potentiels (état initial incohérent, erreur réseau, latence, indisponibilité, etc.).

Comment peut-on qualifier ces nouvelles intéractions ?

➡️ Imprévisibles par nature

La persistance dépend d’un système tiers, dont le comportement peut varier indépendamment du code métier (pannes, délais, erreurs de connexion...).

➡️ Non déterministes

Avec les mêmes entrées, le résultat de l'opération peut différer d'un appel à l'autre :

  • Rien ne garantie de l'existence préalable des comptes sur le système externe, celui-ci peut alors lever une exception.
  • La concurrence d'accès aux données modifiées peut produire des erreurs (dans le cas d'un verrouillage optimiste ou pessimiste par exemple).

➡️ Difficiles à tester directement

Tester cette collaboration nécessite soit d’accéder à un système réel (ce qui introduit de la lenteur et de la fragilité), soit de substituer le collaborateur (par exemple via un test double).

Complexité de l'identification des collaborations volatiles

L'exemple de collaboration volatile présenté précédemment constitue un cas typique, relativement facile à repérer. Cependant, d'autres situations s'avèrent plus délicates à identifier (aléatoire, temporalité, états partagés, etc.).

En amont de toute conception architecturale, il sera essentiel d'examiner ces collaborations volatiles et d'évaluer leur criticité en fonction de la capacité du système à les maîtriser.

Nous évoquerons plus en détail ces cas et leurs répercussions sur le système lorsque nous aborderons le chapître sur la répercussion sur la stratégie de tests.

Implications architecturales

Cette distinction entre contrôlabilité et imprévisibilité guide directement les choix architecturaux. Pour les collaborateurs volatils, l'architecture peut bénéficier de points de substitution permettant de remplacer ces éléments imprévisibles par des équivalents contrôlables (lors des tests par exemple). Cette approche s'avère particulièrement pertinente lorsque la volatilité compromet significativement la testabilité ou la robustesse du système.

C'est dans ce contexte qu'intervient l'inversion de dépendances : plutôt que de dépendre directement d'un collaborateur volatil, le système dépend d'une abstraction qu'il contrôle. Cette inversion transforme une dépendance subie (vers quelque chose d'imprévisible) en une dépendance maîtrisée (vers une interface sous contrôle). Le collaborateur volatil devient alors un détail d'implémentation injecté de l'extérieur, substituable selon les besoins.

La décision d'introduire cette abstraction doit néanmoins être équilibrée : elle apporte de la flexibilité et de la testabilité au prix d'une complexité architecturale supplémentaire. Dans certains cas, accepter une volatilité limitée peut s'avérer plus pragmatique que multiplier les couches d'abstraction.

Les zones stables adoptent en général des couplages plus directs, car leur comportement déterministe ne compromet pas la robustesse du système.

⚙️ Cas d'usage

Reprenons avec notre exemple. Le collaborateur chargé de persister l'état des comptes étant volatile, notre architecture subit quelques modifications :

public interface AccountRepository {
    void save(Account account);
}
Enter fullscreen mode Exit fullscreen mode

AccountRepository est ici une interface contractualisant une intension : Sauvegarder un compte.

public class JpaAccountRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void save(Account account) {
        AccountEntity accountEntity = this.entityManager.find(AccountEntity.class, account.getId());
        accountEntity.setBalance(account.getBalance());
    }

}
Enter fullscreen mode Exit fullscreen mode

JpaAccountRepository est une implémentation concrète de l'interface AccountRepository. Elle aura en charge de persister les données de comptes dans le système externe.

public class TransferMoneyUseCase {
    private final AccountRepository accountRepository;

    public TransferMoneyUseCase(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // ...

}
Enter fullscreen mode Exit fullscreen mode

La classe TransferMoneyUseCase requiert désormais l'injection d'un collaborateur implémentant l'interface AccountRepository. Elle ne dépend plus de son implémentation réelle, mais impose par un contrat la façon dont elle interagira avec le système externe pour sauvegarder les comptes.

AccountRepository accountRepository = new JpaAccountrepository();
TransferMoneyUseCase transferMoneyUseCase = new TransferMoneyUseCase(accountRepository);
Enter fullscreen mode Exit fullscreen mode

L'implémentation concrète JpaAccountRepository est injectée à l'instanciation de notre classe TransferMoneyUseCase, mais ceci devient désormais un détail pour notre cas d'usage.

Répercussions sur la stratégie de tests

La nature des collaborations influence directement la stratégie de test à adopter.

Les collaborateurs stables, étant déterministes et contrôlables, peuvent être conservés dans les tests unitaires, car ils n'introduisent pas d'aléa. Leur présence renforce même la confiance en ces tests puisqu'ils valident les interactions réelles.

Les collaborateurs volatils quant-à-eux doivent être substitués, car leur imprévisibilité compromettrait la fiabilité des tests. Un test unitaire qui dépend d'un service externe imprévisible n'est plus vraiment unitaire puisqu'il n'en respecte pas une des caractéristiques fondamentales : sa reproductibilité.

⚙️ Cas d'usage

Reprenons notre cas d'usage encore une fois. Comment tester désormais de manière prévisible et contrôlée notre système ? C'est là qu'interviennent les tests doubles. Il en existe différents types, mais ce n'est pas le sujet de cet article.

Commençons tout d'abord par vérifier les collaborations stables de notre cas d'usage.

public class AccountRepositoryStub implements AccountRepository {
    public void save(Account account) {
        // do nothing
    }
}

@Test
void shouldTransferMoneyBetweenAccounts() {
    // Arrange
    Account issuer = new Account(UUID.randomUUID(), new BigDecimal("100.00"));
    Account receiver = new Account(UUID.randomUUID(), new BigDecimal("50.00"));
    AccountRepository accountRepository = new AccountRepositoryStub();
    TransferMoneyUseCase useCase = new TransferMoneyUseCase(accountRepository);

    // Act
    useCase.transfer(issuer, receiver, new BigDecimal("30.00"));

    // Assert
    assertEquals(new BigDecimal("70.00"), issuer.getBalance());
    assertEquals(new BigDecimal("80.00"), receiver.getBalance());
}
Enter fullscreen mode Exit fullscreen mode

Dans cet exemple, nous ne vérifions que les collaborations stables, par l'intermédiaire des changements d'états de nos objets Account après la transaction. Utiliser le collaborateur réel pour gérer la persistence nuirait au bon fonctionnement de notre test, c'est pourquoi nous le substituons par un Stub (AccountRepositoryStub) respectant le contrat.

Comment maintenant vérifier que les changements d'état de nos comptes ont bien été sauvegardés dans notre système externe ?

La responsabilité de la sauvegarde physique des données n'est pas à la charge de notre classe TransferMoneyUseCase. Par contre, il est de sa responsabilité d'interagir avec AccountRepository pour effectuer cette sauvegarde. Nous pourrions alors substituer le collaborateur AccountRepository par un Fake, afin de vérifier que le système cible a pris en charge la modification.

Ceci pourrait donner :

public class InMemoryAccountRepository implements AccountRepository {
    private Map<UUID, Account> store;

    public InMemoryAccountRepository(Map<UUID, Account> store) {
        this.store = store;
    }

    public void save(Account account) {
        this.store.put(account.getId(), account);
    }
}

@Test
void shouldSaveAccountsAfterTransfer() {
    // Arrange
    Account issuer = new Account(UUID.randomUUID(), new BigDecimal("100.00"));
    Account receiver = new Account(UUID.randomUUID(), new BigDecimal("50.00"));
    Map<UUID, Account> store = new HashMap<>();
    AccountRepository accountRepository = new InMemoryAccountRepository(store);
    TransferMoneyUseCase useCase = new TransferMoneyUseCase(accountRepository);

    // Act
    useCase.transfer(issuer, receiver, new BigDecimal("30.00"));

    // Assert
    assertTrue(store.contains(issuer.getId()));
    assertEquals(new BigDecimal("70.00"), store.get(issuer.getId()).getBalance());
    assertTrue(store.contains(receiver.getId()));
    assertEquals(new BigDecimal("80.00"), store.get(receiver.getId()).getBalance());
}
Enter fullscreen mode Exit fullscreen mode

Le système externe est désormais simulé par l'implémentation mémoire InMemoryAccountRepository et reproduit de façon minimale le comportement du collaborateur réel JpaAccountRepository. Ce collaborateur est totalement contrôlé et son fonctionnement devient prévisible. Notre test devient alors fiable et robuste, et découplé de l'implémentation réelle.

Conserver le contrôle du système lors des tests

Nous l'avons largement évoqué, l'imprévisibilité conduit forcément à une perte de contrôle du système.

Bien que les collaborations avec des systèmes externes constituent une grande majorité de cas prévus pour être substituables facilement, d'autres sont plus difficilement identifiables et ont un impact direct sur la testabilité.

La testabilité est importante, mais elle ne justifie pas systématiquement l’introduction de complexité : chaque décision de substitution doit s’inscrire dans une logique de compromis éclairé.

En effet, des alternatives à la substitution existent : environnements de test contrôlés (containers), isolation des effets de bord dans des couches dédiées, ou encore outils spécialisés pour gérer certains types de volatilité (environnement, date/zone, etc...). Ces approches peuvent parfois offrir un meilleur équilibre entre simplicité architecturale et contrôle de la testabilité, mais ne seront pas abordées en détail dans cet article.

L'aléatoire

Toute génération de données aléatoires introduit de l'imprévisibilité dans le système. Qu’il s’agisse de la création d’un identifiant unique (UUID), d’une chaîne de caractères, d’un entier pseudo-aléatoire ou d’une opération mathématique incluant une part de hasard, chaque appel peut produire un résultat différent. Cette variabilité fragilise le raisonnement autour du comportement attendu et rend les tests non reproductibles.

Pour restaurer la prédictibilité dans les tests, il pourrait être judicieux d’introduire une dépendance injectable.

Par exemple :

// Inversion de la dépendance entre le système et le collaborateur réel
public interface UUIDProvider {
    UUID get();
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation réelle injectée par défaut dans le système
public class RandomUUIDProvider implements UUIDProvider {
    @Override
    public UUID get() {
        return UUID.randomUUID();
    }
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation contrôlable, injecté depuis les tests.
public class FixedUUIDProvider implements UUIDProvider {
    private final UUID fixedUUID;

    public FixedUUIDProvider(UUID fixedUUID) {
        this.fixedUUID = fixedUUID;
    }

    @Override
    public UUID get() {
        return fixedUUID;
    }
}
Enter fullscreen mode Exit fullscreen mode

La temporalité

Les dépendances temporelles constituent une autre source majeure de volatilité. Lorsqu’un système s’appuie sur la date ou l’heure (date courante, date fixée, jour de la semaine, heure du jour, etc.), son comportement devient dépendant du moment d’exécution. Ce type de dépendance rend les tests non déterministes (ils peuvent échouer ou réussir selon le moment), et empêche toute validation fiable sans contrôle du temps.

Comme précédemment, il conviendra éventuellement de prévoir un mécanisme de substitution.

Par exemple :

// Inversion de la dépendance entre le système et le collaborateur réel
public interface ClockProvider {
    Instant now();
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation réelle injectée par défaut dans le système
public class SystemClockProvider implements ClockProvider {
    @Override
    public Instant now() {
        return Instant.now();
    }
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation contrôlable, injecté depuis les tests.
public class FixedClockProvider implements ClockProvider {
    private final Instant fixedNow;

    public FixedClockProvider(Instant fixedNow) {
        this.fixedNow = fixedNow;
    }

    @Override
    public Instant now() {
        return fixedNow;
    }
}
Enter fullscreen mode Exit fullscreen mode

L'environnement

L’environnement d’exécution peut influencer le comportement d’un composant de manière invisible, mais significative. Cela inclut :

  • les variables d’environnement,
  • les fichiers de configuration,
  • les paramètres système (fuseau horaire, langue, encoding),
  • etc.

Tout accès implicite à ces éléments introduit de la variabilité externe, difficile à anticiper dans un test unitaire. Pour rester prévisible, un test doit fonctionner indépendamment de l’environnement.

Imaginons un cas d'activation / désactivation de fonctionnalité basé sur la valeur d'une variable dans l'environnement. Comment rendre le contrôle et la maitrise au système et permettre sa testabilité ?

// Inversion de la dépendance entre le système et le collaborateur réel
public interface EnvironmentProvider {
    String get(String name);
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation réelle injectée par défaut dans le système qui récupère une variable d'environnement
public class SystemEnvironmentProvider implements EnvironmentProvider {
    @Override
    public String get(String name) {
        return System.getenv(name);
    }
}
Enter fullscreen mode Exit fullscreen mode
// Implémentation contrôlable, injecté depuis les tests. 
public class StubEnvironmentProvider implements EnvironmentProvider {
    private final Map<String, String> values;

    public StubEnvironmentProvider(Map<String, String> values) {
        this.values = values;
    }

    @Override
    public String get(String name) {
        return values.get(name);
    }
}
Enter fullscreen mode Exit fullscreen mode

L’état partagé mutable

Un système devient instable lorsqu’il s’appuie sur un état partagé global et mutable, tel que :

  • des variables static,
  • un cache en mémoire commun à plusieurs tests,
  • ou des ressources singleton accessibles globalement.

Cette approche rend les tests vulnérables aux effets de bord et aux interférences croisées. Un test peut influencer l’état d’un autre sans lien apparent, introduisant des pannes sporadiques difficiles à diagnostiquer.

Une démarche méthodologique

L'analyse de la collaboration doit précéder toute décision architecturale. Elle implique d'identifier les acteurs du système, de cartographier leurs interactions, puis d'évaluer pour chaque collaboration : "Est-ce que je contrôle ce comportement ?", "Est-il nécessaire de garder le contrôle ?" Ces questions simples permettent de catégoriser chaque collaboration selon son degré de contrôlabilité.

Cette démarche permet d'éviter les écueils classiques : sur-ingénierie des zones volatiles par excès d'abstraction, ou sous-estimation de leur complexité. Elle conduit à une architecture adaptant les solutions techniques à la nature réelle des problèmes rencontrés.

La maîtrise des collaborations, centrée sur la notion de contrôlabilité, constitue ainsi le préalable indispensable à toute réflexion architecturale cohérente et à une stratégie de test efficace.

Top comments (0)