· Baptiste Parmantier · Tutorials  · 3 min read

Découvrez l'architecture hexagonale

Développer un bot Mineral c'est bien, découvrez comment mettre en place une architecture hexagonale pour qu'il soit maintenable et évolutif.

Introduction

Dans cet exemple nous prendrons le cas d’usage d’un bot de ticketing.

Structure

├── root
│  ├── src
│  │  ├── infrastructure
│  │  │  ├── services
│  │  │  └── repositories
│  │  │
│  │  ├── domain
│  │  │  ├── models
│  │  │  ├── services
│  │  │  └── contracts
│  │  │
│  │  ├── presentation
│  │  │  ├── commons
│  │  │  └── ticketing
│  │  │
│  │  └── main.dart
│  │
│  ├── pubspec.yaml
│  └── .env
  • infrastructure : contient les implémentations vers différentes sources de données externes, nous pouvons y retrouver des bases de données, caches, services externes, etc.
  • domain : contient les règles métiers de l’application, les modèles de données, les services métiers et les contrats.
  • presentation : contient les points d’entré du module, dans notre cadre, nous pouvons envisager des handlers écoutant des évènements reçus depuis Discord

Modèles

Nous allons créer les models :

final class SubjectTicket {
  final int uid;
  final String title;
  final String description;
  
  SubjectTicket({
    required this.uid,
    required this.title, 
    required this.description, 
  });
}
final class Ticket {
  final int id;
  final String subject;
  final String ownerId;

  Ticket({
    required this.id,
    required this.subject,
    required this.ownerId,
  });
  
  factory Ticket.fromMap(Map<String, dynamic> map) {
    return Ticket(
      id: map['id'],
      subject: map['subject'],
      ownerId: map['owner_id'],
    );
  }
}

Services

abstract interface class DatabaseContract {
  Executor get execute;
  Future<void> init();
}

final class Database implements DatabaseContract {
  final Connection _connection;

  Executor get execute => _connection.execute;
  
  Future<void> init() async {
    final conn = await Connection.open(Endpoint(
      host: 'localhost',
      database: 'postgres',
      username: 'user',
      password: 'pass',
    ));
  }
}
abstract interface class TicketRepositoryContract {
  Future<Ticket> create(payload: CreateTicketPayload);
  Future<voi> delete(int id);
}

final class TicketService implements TicketRepositoryContract {
  final DatabaseContract _database;
  
  Future<Ticket> create(payload: CreateTicketPayload) async {
    final result = await _database.execute(
      Sql.named('INSERT INTO tickets (subject, owner_id) VALUES (@subject, @ownerId)'),
      parameters: { 'subject': payload.subject, 'ownerId': payload.ownerId }
    );
    
    return Ticket.fromMap(result);
  }
  
  Future<void> delete(int id) {
    await _database.execute(
      Sql.named('DELETE FROM tickets WHERE id = @id'),
      parameters: { 'id': id }
    );
  }
}
abstract interface class TicketServiceContract {
  List<SubjectTicket> get subjects;
  Future<void> createTicket({ required SubjectTicket subject, required Member member});
}

final class TicketService with InjectLogger implements TicketServiceContract {  
  @override
  final List<SubjectTicket> subjects = [
    SubjectTicket(
      uid: 1,
      title: 'Report member',
      description: 'Report a member to the staff'),

    SubjectTicket(
      uid: 2,
      title: 'Ask a question',
      description: 'Ask a question to the staff')
  ];

  final TicketRepositoryContract _ticketRepository;
  TicketService(this._ticketRepository);

  Future<TextChannel> createTicket({required SubjectTicket subject, required CommandContext ctx}) async {
    try {
      await _ticketRepository.create(CreateTicketPayload(
        subject: subject.uid,
        ownerId: member.id
      ));
      
      return member.guild.channels.create('ticket-${member.id}');
    } catch(err) {
      logger.error('An error occurred while creating a ticket: $err');
    }
  }
}

Dans cette partie de l’application nous allons utiliser plusieurs sources d’information entrantes telles que :

  • events : les évènements reçus depuis Discord
  • commands : les commandes reçues depuis Discord

Nous commencerons par créer une commande nous permettant d’interagir avec les tickets :

final class MyCommand implements CommandDeclaration {
  final TicketServiceContract _ticketService;

  MyCommand(this._ticketService);
  
  FutureOr<void> handle(CommandContext ctx, {required int subject}) async {
    final targetSubject = _ticketService.subjects
      .firstWhere((element) => element.uid == subject);
    
    await _ticketService.createTicket(
      subject: targetSubject, 
      member: member
    );
    
    ctx.reply('Selected value: $value');
  }
  
  @override
  CommandDeclarationBuilder build() {
    return CommandDeclarationBuilder()
      ..setName('foo')
      ..setDescription('This is a command description')
      ..setHandler(handle)
      ..addOption(
        ChoiceOption.integer(
          name: 'subject',
          description: 'Select a subject',
          required: true,
          choices: _ticketService.subjects
            .map((e) => Choice(e.title, e.uid,))
            .toList()
        ));
  }
}
Back to Blog

Related Posts

View All Posts »
AstroWind template in depth

AstroWind template in depth

While easy to get started, Astrowind is quite complex internally. This page provides documentation on some of the more intricate parts.

Markdown elements demo post

Sint sit cillum pariatur eiusmod nulla pariatur ipsum. Sit laborum anim qui mollit tempor pariatur nisi minim dolor.