Les applications modernes nécessitent des systèmes de notification robustes capables de transmettre des messages sur plusieurs canaux – email, SMS, notifications push, Slack, etc. Le défi principal est de construire un système qui reste flexible et durable à mesure que de nouveaux canaux émergent, sans exiger une réécriture du code existant. Le modèle de conception Abstract Factory offre une solution éprouvée dans le temps en fournissant une interface pour créer des familles d'objets connexes sans couplage le code client avec des implémentations concrètes.

Comprendre le modèle abstrait de l'usine

Le motif Abstract Factory est l'un des modèles de création originaux du Gang of Four. Il fournit une interface pour créer des familles d'objets apparentés ou dépendants sans spécifier leurs classes de béton. Le motif est particulièrement utile lorsque:

  • Un système doit être indépendant de la façon dont ses produits sont créés, composés ou représentés.
  • Un système doit être configuré avec une des familles multiples de produits.
  • Vous voulez faire respecter la cohérence entre les produits qui appartiennent ensemble.
  • Vous ajoutez de nouvelles familles de produits fréquemment, et vous voulez éviter de modifier le code existant.

Les participants au schéma

  • AbstractFactory[: déclare une interface pour un ensemble de méthodes de création, chaque retour d'un produit abstrait.
  • ConcreteFactory: met en œuvre les méthodes de création pour produire des produits concrets.
  • AbstractProduct: déclare une interface pour un type d'objet produit.
  • ConcreteProduct: définit un objet produit qui implémente l'interface AbstractProduct.
  • Client: utilise uniquement des interfaces déclarées par AbstractFactory et AbstractProduct.

Dans le contexte d'un système de notification, AbstractProduct correspond à l'interface , et ConcreteProduct[ correspond à , , etc. Le AbstractFactory[ est , et chaque ConcreteFactory[ instancie un type de notification spécifique.

Pourquoi Kotlin pour la mise en œuvre de la conception de modèle?

Kotlin apporte plusieurs fonctionnalités de langage moderne qui rendent la mise en œuvre du modèle Abstract Factory plus concis et plus sûr qu'en Java:

  • Classes scellées pour représenter un ensemble fermé de types de notification, permettant des expressions exhaustives .
  • Classes de données[ pour réduire la plaque de chaudière dans les implémentations de produits.
  • Déclarations d'objets[ pour créer des usines de monotones sans code supplémentaire.
  • Fonctions d'ordre supérieur pour remplacer les interfaces d'usines avec les usines lambda, le cas échéant.
  • Sécurité des null et type inférence[ pour réduire les erreurs d'exécution.

Ces fonctionnalités vous permettent d'écrire un système d'usine qui est non seulement flexible, mais aussi sûr de compiler temps, réduisant le besoin de la réflexion ou de contrôles de type d'exécution.

Construire un système de notification de base avec Abstract Factory

Laissez-vous aller à Kotlin. Nous allons commencer par une version minimale et ensuite l'étendre pour gérer la complexité réelle.

1. Définir l'interface de produit abstrait

Chaque notification doit exposer une méthode . Nous incluons également une propriété pour soutenir le routage et l'enregistrement.

interface Notification {
 val type: String
 fun send(message: String): Result
}

data class Result(val success: Boolean, val error: String? = null)

2. Mettre en œuvre des produits de béton

En utilisant la syntaxe concise de Kotlin, nous créons des implémentations concrètes pour les notifications par courriel, SMS et push. Pour le réalisme, chaque implémentation contiendrait des appels d'API réels, mais ici nous les simulons.

class EmailNotification : Notification {
 override val type = "email"
 override fun send(message: String): Result {
 // Simulate sending email via SMTP or service like SendGrid
 println("Sending email: $message")
 return Result(true)
 }
}

class SmsNotification : Notification {
 override val type = "sms"
 override fun send(message: String): Result {
 // Simulate sending SMS via Twilio or similar
 println("Sending SMS: $message")
 return Result(true)
 }
}

class PushNotification : Notification {
 override val type = "push"
 override fun send(message: String): Result {
 // Simulate sending push via Firebase Cloud Messaging
 println("Sending push notification: $message")
 return Result(true)
 }
}

3. Créer l'interface de l'usine abstraite

L'usine déclare une seule méthode de création. Dans les systèmes plus complexes, vous pouvez avoir plusieurs méthodes de création (par exemple, , ), mais pour une usine simple, nous la conservons générique.

interface NotificationFactory {
 fun createNotification(): Notification
}

4. Mettre en œuvre des usines de béton

Chaque usine est responsable de l'instantannage d'un type de produit. L'utilisation d'une déclaration fait de l'usine un simpleton, ce qui est souvent suffisant.

object EmailNotificationFactory : NotificationFactory {
 override fun createNotification(): Notification = EmailNotification()
}

object SmsNotificationFactory : NotificationFactory {
 override fun createNotification(): Notification = SmsNotification()
}

object PushNotificationFactory : NotificationFactory {
 override fun createNotification(): Notification = PushNotification()
}

5. Utiliser les usines du Code du client

Le client (p. ex., un service de notification) accepte un et envoie un message sans connaître la classe de produits en béton.

fun sendNotification(factory: NotificationFactory, message: String) {
 val notification = factory.createNotification()
 val result = notification.send(message)
 if (!result.success) {
 println("Failed to send ${notification.type}: ${result.error}")
 }
}

fun main() {
 // In a real app, the factory choice would come from configuration or user preferences
 sendNotification(EmailNotificationFactory, "Welcome to our service!")
 sendNotification(SmsNotificationFactory, "Your code is 1234")
 sendNotification(PushNotificationFactory, "New message from Bob")
}

Cette configuration de base démontre déjà la puissance de la configuration : l'ajout d'un nouveau canal (p. ex. Slack) nécessite seulement une nouvelle classe de produits et une nouvelle usine – aucun changement au code client ou aux usines existantes.

Extension du système avec de nouveaux canaux de notification

Supposons que nous ayons besoin d'ajouter des notifications Slack. Nous créons et . La méthode interagirait avec l'API Web de Slack.

class SlackNotification(private val webhookUrl: String) : Notification {
 override val type = "slack"
 override fun send(message: String): Result {
 // POST to webhookUrl
 return if (webhookUrl.isNotBlank()) {
 println("Sending Slack message: $message")
 Result(true)
 } else {
 Result(false, "Webhook URL not configured")
 }
 }
}

object SlackNotificationFactory : NotificationFactory {
 override fun createNotification(): Notification = SlackNotification(CONFIG_SLACK_WEBHOOK)
}

Le code client reste intact. C'est la marque du principe ouvert/fermé en action : le système est ouvert pour l'extension mais fermé pour la modification.

Considérations avancées pour les systèmes de production et de préparation

Alors que le modèle de base fonctionne, les systèmes de notification du monde réel exigent plus de sophistication.

Classes scellées pour la sélection d'usines exhaustives

Au lieu de passer une usine abstraite, vous pouvez utiliser Kotlins pour représenter les types de notification et laisser le client choisir quelle usine utiliser via une expression . Le compilateur vous force à gérer chaque type lors de l'ajout d'un nouveau canal de notification.

sealed class NotificationType {
 object Email : NotificationType()
 object SMS : NotificationType()
 object Push : NotificationType()
 data class Slack(val webhookUrl: String) : NotificationType()
}

fun createNotification(type: NotificationType): Notification = when (type) {
 NotificationType.Email -> EmailNotification()
 NotificationType.SMS -> SmsNotification()
 NotificationType.Push -> PushNotification()
 is NotificationType.Slack -> SlackNotification(type.webhookUrl)
}

Cette approche élimine l'interface d'usine séparée et utilise plutôt une fonction unique qui retourne le produit correct. Elle est plus simple pour les petits à moyens systèmes et vous donne la sécurité de compilation-temps.

Envoi de notifications asynchrones

La plupart des canaux de notification impliquent des appels réseau. Le retour synchrone est impossible. Au lieu de cela, faire suspendre ou renvoyer une coroutine .

interface Notification {
 suspend fun send(message: String): Result
}

class EmailNotification : Notification {
 override suspend fun send(message: String): Result {
 // Use Http client to send email
 return withContext(Dispatchers.IO) {
 // actual API call
 Result(true)
 }
 }
}

Le code client peut alors appeler depuis une portée coroutine :

suspend fun sendNotification(factory: NotificationFactory, message: String) {
 val notification = factory.createNotification()
 val result = notification.send(message)
 // handle result
}

Intégration de l'injection de dépendance

Au lieu d'usines codées en dur, utilisez un cadre d'injection de dépendance comme Koin ou Dagger pour relier les usines à l'interface . Cela vous permet d'échanger des implémentations dans des tests ou des configurations.

// Koin module
val notificationModule = module {
 factory<NotificationFactory>("email") { EmailNotificationFactory }
 factory<NotificationFactory>("sms") { SmsNotificationFactory }
 factory<NotificationFactory>("push") { PushNotificationFactory }
}

Ensuite, obtenir la bonne usine de Koin à l'exécution en fonction d'une clé de configuration.

Gestion des erreurs et retraits

Les notifications échouent souvent en raison d'erreurs transitoires. Enveloppez la création de l'usine et du produit avec la manipulation des erreurs. Vous pouvez également implémenter un mécanisme de réessayer en utilisant Kotlin coroutines.

suspend fun sendWithRetry(factory: NotificationFactory, message: String, maxRetries: Int = 3): Result {
 repeat(maxRetries) { attempt ->
 try {
 val notification = factory.createNotification()
 return notification.send(message)
 } catch (e: Exception) {
 if (attempt == maxRetries - 1) {
 return Result(false, e.message)
 }
 delay(1000L * (attempt + 1)) // exponential backoff
 }
 }
 return Result(false, "Max retries exceeded")
}

Modèle et personnalisation

Les notifications nécessitent souvent une templatation (p. ex., courriel HTML, contenu dynamique de SMS). L'usine peut renvoyer un objet de notification préconfiguré avec un moteur de gabarit. Par exemple :

class TemplatedEmailNotification(private val template: String) : Notification {
 override val type = "email"
 override fun send(message: String): Result {
 val body = processTemplate(template, mapOf("content" to message))
 // Send email with rendered body
 return Result(true)
 }
}

L'usine pourrait alors créer différentes variantes de produits en fonction de paramètres supplémentaires.

Essai du système de notification

L'un des plus grands avantages du modèle Abstract Factory est la testabilité. Vous pouvez facilement créer des usines de simulation qui retournent test doubles.

class MockNotification : Notification {
 var sentMessage: String? = null
 override val type = "mock"
 override fun send(message: String): Result {
 sentMessage = message
 return Result(true)
 }
}

object MockNotificationFactory : NotificationFactory {
 val mock = MockNotification()
 override fun createNotification(): Notification = mock
}

// In test:
val factory = MockNotificationFactory
sendNotification(factory, "Test message")
assertEquals("Test message", factory.mock.sentMessage)

Vous pouvez également écrire des tests unitaires qui vérifient que l'usine retourne le type de produit correct, en utilisant des matcheurs de style Kotlins .

Configuration des usines à Runtime

Dans une architecture de microservice, le choix du canal de notification peut provenir d'un fichier de configuration, d'une variable d'environnement ou d'une base de données. Vous pouvez implémenter un registre qui mastique les clés de chaîne vers les instances d'usine.

object NotificationFactoryRegistry {
 private val factories = mutableMapOf<String, NotificationFactory>()

 fun register(channel: String, factory: NotificationFactory) {
 factories[channel] = factory
 }

 fun getFactory(channel: String): NotificationFactory? = factories[channel]
}

Puis, populez le registre au démarrage de l'application à partir de ou d'une recherche de base de données. Cela découple la décision de l'usine d'utiliser entièrement du code client.

Comparaison avec le modèle de méthode d'usine

La méthode Abstract Factory est souvent confondue avec la méthode Factory plus simple. La différence principale est que la méthode Factory traite d'un seul produit, tandis que Abstract Factory traite de familles de produits connexes. Dans notre système de notification, si vous aviez plusieurs produits connexes par canal (p. ex. un notifiant et un enregistreur spécifique à ce canal), vous utiliseriez Abstract Factory. Pour un seul produit par canal, la méthode Factory (ou l'approche de classe scellée) peut être suffisante.

Tout mettre en place : un exemple du monde réel

Imaginez une plateforme SaaS qui permet aux utilisateurs de choisir leurs préférences de notification : email, SMS, push, ou Slack. Le système lit les préférences des utilisateurs à partir d'une base de données, recherche l'usine correspondante (injectée ou obtenue d'un registre), et envoie la notification. En utilisant le modèle Abstract Factory, ajouter un nouveau canal (par exemple, Microsoft Teams) nécessite simplement une nouvelle classe de produits et une nouvelle – et l'enregistrer dans le registre d'usine.

// Preference model
data class UserPreference(
 val userId: String,
 val channel: String // "email", "sms", "push", "slack"
)

class NotificationDispatcher(private val registry: NotificationFactoryRegistry) {
 suspend fun dispatch(preference: UserPreference, message: String) {
 val factory = registry.getFactory(preference.channel)
 ?: throw IllegalArgumentException("Unknown channel: ${preference.channel}")
 val notification = factory.createNotification()
 notification.send(message)
 }
}

Ce design est non seulement flexible, mais aussi très testable : vous pouvez vous moquer du registre et vérifier que l'usine correcte est utilisée pour chaque canal.

Meilleures pratiques pour la mise en œuvre de l'usine abstraite à Kotlin

  • Préférez les classes scellées sur les définitions d'interfaces lâches lorsque l'ensemble de produits est fermé et connu au moment de la compilation. Il vous donne des vérifications exhaustives.
  • Utilisez des objets ou des déclarations d'objets pour les usines qui n'ont pas d'état.
  • L'utilisation de valeurs de paramètre par défaut[ dans les constructeurs de produits pour fournir des valeurs par défaut raisonnables sans surcharger les usines.
  • Gardez l'interface d'usine minimale. Si vous vous trouvez à ajouter de nombreuses méthodes de création, envisagez d'utiliser un modèle de constructeur ou un objet de configuration.
  • Faire des fonctions suspend[ pour éviter de bloquer les fils. Les coroutines de Kotlin s'intègrent naturellement à des motifs comme Abstract Factory.
  • Documenter les responsabilités de chaque usine et de son produit. Comme le modèle ajoute une indication indirecte, il est essentiel de donner clairement des noms et de fournir de la documentation.

Ressources extérieures

Pour approfondir votre compréhension du modèle Abstract Factory et de sa mise en œuvre à Kotlin, consultez les ressources suivantes :

  • Résumé Manufacture Pattern – Guru – une explication complète avec des diagrammes UML et des exemples de code.
  • Classes scellées de Kotlin – documentation officielle sur la façon dont les classes scellées peuvent modéliser les hiérarchies de classe restreinte.
  • Kotlin in Action – le livre définitif sur Kotlin, y compris les implémentations de modèles de conception (le chapitre 11 couvre les modèles).

En combinant le modèle Abstract Factory éprouvé avec les fonctionnalités de langage moderne de Kotlin, vous pouvez construire un système de notification non seulement flexible et évolutive, mais aussi sûr et durable. Que vous souteniez deux canaux ou vingt, le modèle garantit que l'ajout de nouvelles capacités ne se sent jamais comme une réécriture – il devient une extension prévisible et à faible risque.