diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/.editorconfig b/04-eventual_consistency/2-ensure_event_is_always_published/.editorconfig
new file mode 100644
index 0000000..e6e6a12
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/.editorconfig
@@ -0,0 +1,11 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.java]
+indent_size = 4
+indent_style = tab
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/.env b/04-eventual_consistency/2-ensure_event_is_always_published/.env
new file mode 100644
index 0000000..b934e03
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/.env
@@ -0,0 +1 @@
+# See apps/main/resources/.env
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/.github/workflows/ci.yml b/04-eventual_consistency/2-ensure_event_is_always_published/.github/workflows/ci.yml
new file mode 100644
index 0000000..8dcdf5b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: 🐳 Start all the environment
+ run: make start
+
+ - name: 🔦 Lint
+ run: make lint
+
+ - name: 🦭 Wait for the environment to get up
+ run: |
+ while ! make ping-mysql &>/dev/null; do
+ echo "Waiting for database connection..."
+ sleep 2
+ done
+
+ - name: ✅ Run the tests
+ run: make test
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/.gitignore b/04-eventual_consistency/2-ensure_event_is_always_published/.gitignore
new file mode 100644
index 0000000..8d89f10
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/.gitignore
@@ -0,0 +1,12 @@
+# Gradle
+.gradle
+build/
+
+# Ignore Gradle GUI config
+gradle-app.setting
+
+/var/log/*
+!/var/log/.gitkeep
+
+.env.local
+.env.*.local
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/Dockerfile b/04-eventual_consistency/2-ensure_event_is_always_published/Dockerfile
new file mode 100644
index 0000000..98f0b27
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/Dockerfile
@@ -0,0 +1,15 @@
+FROM openjdk:21-slim-buster
+WORKDIR /app
+
+RUN apt update && apt install -y curl git
+
+ENV NVM_DIR /usr/local/nvm
+ENV NODE_VERSION 18.0.0
+
+RUN mkdir -p $NVM_DIR
+RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash
+
+RUN . $NVM_DIR/nvm.sh && nvm install $NODE_VERSION && nvm alias default $NODE_VERSION && nvm use default
+
+ENV NODE_PATH $NVM_DIR/versions/node/v$NODE_VERSION/lib/node_modules
+ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/Makefile b/04-eventual_consistency/2-ensure_event_is_always_published/Makefile
new file mode 100644
index 0000000..c312ce1
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/Makefile
@@ -0,0 +1,29 @@
+all: build
+
+start:
+ @docker-compose -f docker-compose.ci.yml up -d
+
+build:
+ @./gradlew build --warning-mode all
+
+lint:
+ @docker exec codely-java_ddd_example-test_server ./gradlew spotlessCheck
+
+run-tests:
+ @./gradlew test --warning-mode all
+
+test:
+ @docker exec codely-java_ddd_example-test_server ./gradlew test --warning-mode all
+
+run:
+ @./gradlew :run
+
+ping-mysql:
+ @docker exec codely-java_ddd_example-mysql mysqladmin --user=root --password= --host "127.0.0.1" ping --silent
+
+# Start the app
+start-mooc_backend:
+ @./gradlew bootRun --args='mooc_backend server'
+
+start-backoffice_frontend:
+ @./gradlew bootRun --args='backoffice_frontend server'
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/README.md b/04-eventual_consistency/2-ensure_event_is_always_published/README.md
new file mode 100644
index 0000000..6c6bb26
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/README.md
@@ -0,0 +1,52 @@
+# ☕🚀 Java DDD example: Save the boilerplate in your new projects
+
+
+
+
+> ⚡ Start your Java projects as fast as possible
+
+[](https://codely.tv)
+[](https://github.com/CodelyTV/java-ddd-example/actions)
+
+## ℹ️ Introduction
+
+This is a repository intended to serve as a starting point if you want to bootstrap a Java project with JUnit and Gradle.
+
+Here you have the [course on CodelyTV Pro where we explain step by step all this](https://pro.codely.tv/library/ddd-en-java/about/?utm_source=github&utm_medium=social&utm_campaign=readme) (Spanish)
+
+## 🏁 How To Start
+
+1. Install Java 11: `brew cask install corretto`
+2. Set it as your default JVM: `export JAVA_HOME='/Library/Java/JavaVirtualMachines/amazon-corretto-11.jdk/Contents/Home'`
+3. Clone this repository: `git clone https://github.com/CodelyTV/java-ddd-example`.
+4. Bring up the Docker environment: `make up`.
+5. Execute some [Gradle lifecycle tasks](https://docs.gradle.org/current/userguide/java_plugin.html#lifecycle_tasks) in order to check everything is OK:
+ 1. Create [the project JAR](https://docs.gradle.org/current/userguide/java_plugin.html#sec:jar): `make build`
+ 2. Run the tests and plugins verification tasks: `make test`
+6. Start developing!
+
+## ☝️ How to update dependencies
+
+* Gradle ([releases](https://gradle.org/releases/)): `./gradlew wrapper --gradle-version=WANTED_VERSION --distribution-type=bin`
+
+## 💡 Related repositories
+
+### ☕ Java
+
+* 📂 [Java Basic example](https://github.com/CodelyTV/java-basic-example)
+* ⚛ [Java OOP Examples](https://github.com/CodelyTV/java-oop-examples)
+* 🧱 [Java SOLID Examples](https://github.com/CodelyTV/java-solid-examples)
+* 🥦 [Java DDD Example](https://github.com/CodelyTV/java-ddd-example)
+
+### 🐘 PHP
+
+* 📂 [PHP Basic example](https://github.com/CodelyTV/php-basic-example)
+* 🎩 [PHP DDD example](https://github.com/CodelyTV/php-ddd-example)
+* 🥦 [PHP DDD Example](https://github.com/CodelyTV/php-ddd-example)
+
+### 🧬 Scala
+
+* 📂 [Scala Basic example](https://github.com/CodelyTV/scala-basic-example)
+* ⚡ [Scala Basic example (g8 template)](https://github.com/CodelyTV/scala-basic-example.g8)
+* ⚛ [Scala Examples](https://github.com/CodelyTV/scala-examples)
+* 🥦 [Scala DDD Example](https://github.com/CodelyTV/scala-ddd-example)
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/.env b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/.env
new file mode 100644
index 0000000..cd58c25
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/.env
@@ -0,0 +1,34 @@
+# MOOC #
+#--------------------------------#
+MOOC_BACKEND_SERVER_PORT=8030
+# MySql
+MOOC_DATABASE_HOST=codely-java_ddd_example-mysql
+MOOC_DATABASE_PORT=3306
+MOOC_DATABASE_NAME=mooc
+MOOC_DATABASE_USER=root
+MOOC_DATABASE_PASSWORD=
+
+# BACKOFFICE #
+#--------------------------------#
+BACKOFFICE_BACKEND_SERVER_PORT=8040
+BACKOFFICE_FRONTEND_SERVER_PORT=8041
+# MySql
+BACKOFFICE_DATABASE_HOST=codely-java_ddd_example-mysql
+BACKOFFICE_DATABASE_PORT=3306
+BACKOFFICE_DATABASE_NAME=backoffice
+BACKOFFICE_DATABASE_USER=root
+BACKOFFICE_DATABASE_PASSWORD=
+# Elasticsearch
+BACKOFFICE_ELASTICSEARCH_HOST=codely-java_ddd_example-elasticsearch
+BACKOFFICE_ELASTICSEARCH_PORT=9200
+BACKOFFICE_ELASTICSEARCH_INDEX_PREFIX=backoffice
+
+# COMMON #
+#--------------------------------#
+# RabbitMQ
+RABBITMQ_HOST=codely-java_ddd_example-rabbitmq
+RABBITMQ_PORT=5672
+RABBITMQ_LOGIN=codely
+RABBITMQ_PASSWORD=c0d3ly
+RABBITMQ_EXCHANGE=domain_events
+RABBITMQ_MAX_RETRIES=5
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/public/images/logo.png b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/public/images/logo.png
new file mode 100644
index 0000000..7593959
Binary files /dev/null and b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/public/images/logo.png differ
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/master.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/master.ftl
new file mode 100644
index 0000000..0a8bc23
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/master.ftl
@@ -0,0 +1,27 @@
+<#import "spring.ftl" as spring />
+
+
+
+
+
+
+
+
+
+ ${title}
+ ${description}
+
+
+<#include "partials/header.ftl">
+
+
+
<@page_title/>
+ <@main/>
+
+
+
+
+<#include "partials/footer.ftl">
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/courses.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/courses.ftl
new file mode 100644
index 0000000..8b4c99c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/courses.ftl
@@ -0,0 +1,20 @@
+<#include "../../master.ftl">
+
+<#macro page_title>Cursos#macro>
+
+<#macro main>
+
+

+
+
Cursos
+
+ Actualmente CodelyTV Pro cuenta con ${courses_counter} cursos.
+
+
+
+
+ <#include "partials/new_course_form.ftl">
+
+
+ <#include "partials/list_courses.ftl">
+#macro>
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/list_courses.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/list_courses.ftl
new file mode 100644
index 0000000..e81af3c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/list_courses.ftl
@@ -0,0 +1,153 @@
+Cursos existentes
+
+
+
+
+
+
+
+ Id
+ |
+
+ Nombre
+ |
+
+ Duración
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/new_course_form.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/new_course_form.ftl
new file mode 100644
index 0000000..b7ecd5f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/courses/partials/new_course_form.ftl
@@ -0,0 +1,54 @@
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/home.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/home.ftl
new file mode 100644
index 0000000..8ee5f2b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/pages/home.ftl
@@ -0,0 +1,7 @@
+<#include "../master.ftl">
+
+<#macro page_title>HOME#macro>
+
+<#macro main>
+ Estamos en la home!
+#macro>
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/footer.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/footer.ftl
new file mode 100644
index 0000000..27a640f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/footer.ftl
@@ -0,0 +1,7 @@
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/header.ftl b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/header.ftl
new file mode 100644
index 0000000..3f2f738
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/backoffice_frontend/templates/partials/header.ftl
@@ -0,0 +1,28 @@
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/log4j2.properties b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/log4j2.properties
new file mode 100644
index 0000000..b577541
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/resources/log4j2.properties
@@ -0,0 +1,34 @@
+name = CodelyTvJavaDddExample
+property.filename = logs
+appenders = console, file
+
+status = warn
+
+appender.console.name = CONSOLE
+appender.console.type = CONSOLE
+appender.console.target = SYSTEM_OUT
+
+appender.console.logstash.type = LogstashLayout
+appender.console.logstash.dateTimeFormatPattern = yyyy-MM-dd'T'HH:mm:ss.SSSZZZ
+appender.console.logstash.eventTemplateUri = classpath:LogstashJsonEventLayoutV1.json
+appender.console.logstash.prettyPrintEnabled = true
+appender.console.logstash.stackTraceEnabled = true
+
+appender.file.type = File
+appender.file.name = LOGFILE
+appender.file.fileName = var/log/java-ddd-example.log
+appender.file.logstash.type = LogstashLayout
+appender.file.logstash.dateTimeFormatPattern = yyyy-MM-dd'T'HH:mm:ss.SSSZZZ
+appender.file.logstash.eventTemplateUri = classpath:LogstashJsonEventLayoutV1.json
+appender.file.logstash.prettyPrintEnabled = false
+appender.file.logstash.stackTraceEnabled = true
+
+loggers = file
+logger.file.name = tv.codely.java_ddd_example
+logger.file.level = info
+logger.file.appenderRefs = file
+logger.file.appenderRef.file.ref = LOGFILE
+
+rootLogger.level = info
+rootLogger.appenderRefs = stdout
+rootLogger.appenderRef.stdout.ref = CONSOLE
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/Starter.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/Starter.java
new file mode 100644
index 0000000..64ad963
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/Starter.java
@@ -0,0 +1,96 @@
+package tv.codely.apps;
+
+import java.util.Arrays;
+import java.util.HashMap;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.WebApplicationType;
+import org.springframework.context.ConfigurableApplicationContext;
+
+import tv.codely.apps.backoffice.backend.BackofficeBackendApplication;
+import tv.codely.apps.backoffice.frontend.BackofficeFrontendApplication;
+import tv.codely.apps.mooc.backend.MoocBackendApplication;
+import tv.codely.shared.infrastructure.cli.ConsoleCommand;
+
+public class Starter {
+
+ public static void main(String[] args) {
+ if (args.length < 2) {
+ throw new RuntimeException("There are not enough arguments");
+ }
+
+ String applicationName = args[0];
+ String commandName = args[1];
+ boolean isServerCommand = commandName.equals("server");
+
+ ensureApplicationExist(applicationName);
+ ensureCommandExist(applicationName, commandName);
+
+ Class> applicationClass = applications().get(applicationName);
+
+ SpringApplication app = new SpringApplication(applicationClass);
+
+ if (!isServerCommand) {
+ app.setWebApplicationType(WebApplicationType.NONE);
+ }
+
+ ConfigurableApplicationContext context = app.run(args);
+
+ if (!isServerCommand) {
+ ConsoleCommand command = (ConsoleCommand) context.getBean(commands().get(applicationName).get(commandName));
+
+ command.execute(Arrays.copyOfRange(args, 2, args.length));
+ }
+ }
+
+ private static void ensureApplicationExist(String applicationName) {
+ if (!applications().containsKey(applicationName)) {
+ throw new RuntimeException(
+ String.format(
+ "The application <%s> doesn't exist. Valids:\n- %s",
+ applicationName,
+ String.join("\n- ", applications().keySet())
+ )
+ );
+ }
+ }
+
+ private static void ensureCommandExist(String applicationName, String commandName) {
+ if (!"server".equals(commandName) && !existCommand(applicationName, commandName)) {
+ throw new RuntimeException(
+ String.format(
+ "The command <%s> for application <%s> doesn't exist. Valids (application.command):\n- api\n- %s",
+ commandName,
+ applicationName,
+ String.join("\n- ", commands().get(applicationName).keySet())
+ )
+ );
+ }
+ }
+
+ private static HashMap> applications() {
+ HashMap> applications = new HashMap<>();
+
+ applications.put("mooc_backend", MoocBackendApplication.class);
+ applications.put("backoffice_backend", BackofficeBackendApplication.class);
+ applications.put("backoffice_frontend", BackofficeFrontendApplication.class);
+
+ return applications;
+ }
+
+ private static HashMap>> commands() {
+ HashMap>> commands = new HashMap<>();
+
+ commands.put("mooc_backend", MoocBackendApplication.commands());
+ commands.put("backoffice_backend", BackofficeBackendApplication.commands());
+ commands.put("backoffice_frontend", BackofficeFrontendApplication.commands());
+
+ return commands;
+ }
+
+ private static Boolean existCommand(String applicationName, String commandName) {
+ HashMap>> commands = commands();
+
+ return commands.containsKey(applicationName) && commands.get(applicationName).containsKey(commandName);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/BackofficeBackendApplication.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/BackofficeBackendApplication.java
new file mode 100644
index 0000000..873122f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/BackofficeBackendApplication.java
@@ -0,0 +1,24 @@
+package tv.codely.apps.backoffice.backend;
+
+import java.util.HashMap;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+
+import tv.codely.shared.domain.Service;
+
+@SpringBootApplication(exclude = HibernateJpaAutoConfiguration.class)
+@ComponentScan(
+ includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Service.class),
+ value = { "tv.codely.shared", "tv.codely.backoffice", "tv.codely.apps.backoffice.backend" }
+)
+public class BackofficeBackendApplication {
+
+ public static HashMap> commands() {
+ return new HashMap>() {
+ {}
+ };
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/command/.gitkeep b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/command/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerConfiguration.java
new file mode 100644
index 0000000..80314c2
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerConfiguration.java
@@ -0,0 +1,28 @@
+package tv.codely.apps.backoffice.backend.config;
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import tv.codely.apps.backoffice.backend.middleware.BasicHttpAuthMiddleware;
+import tv.codely.shared.domain.bus.command.CommandBus;
+
+@Configuration
+public class BackofficeBackendServerConfiguration {
+
+ private final CommandBus bus;
+
+ public BackofficeBackendServerConfiguration(CommandBus bus) {
+ this.bus = bus;
+ }
+
+ @Bean
+ public FilterRegistrationBean basicHttpAuthMiddleware() {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+
+ registrationBean.setFilter(new BasicHttpAuthMiddleware(bus));
+ registrationBean.addUrlPatterns("/health-check");
+
+ return registrationBean;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerPortCustomizer.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerPortCustomizer.java
new file mode 100644
index 0000000..8c46d55
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/config/BackofficeBackendServerPortCustomizer.java
@@ -0,0 +1,28 @@
+package tv.codely.apps.backoffice.backend.config;
+
+import org.springframework.boot.web.server.ConfigurableWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+
+@Component
+public final class BackofficeBackendServerPortCustomizer
+ implements WebServerFactoryCustomizer {
+
+ private final Parameter param;
+
+ public BackofficeBackendServerPortCustomizer(Parameter param) {
+ this.param = param;
+ }
+
+ @Override
+ public void customize(ConfigurableWebServerFactory factory) {
+ try {
+ factory.setPort(param.getInt("BACKOFFICE_BACKEND_SERVER_PORT"));
+ } catch (ParameterNotExist parameterNotExist) {
+ parameterNotExist.printStackTrace();
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/courses/CoursesGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/courses/CoursesGetController.java
new file mode 100644
index 0000000..48bb8b4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/courses/CoursesGetController.java
@@ -0,0 +1,85 @@
+package tv.codely.apps.backoffice.backend.controller.courses;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.*;
+
+import tv.codely.backoffice.courses.application.BackofficeCoursesResponse;
+import tv.codely.backoffice.courses.application.search_by_criteria.SearchBackofficeCoursesByCriteriaQuery;
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.domain.bus.query.QueryHandlerExecutionError;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+@CrossOrigin(origins = "*", methods = { RequestMethod.GET })
+public final class CoursesGetController extends ApiController {
+
+ public CoursesGetController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @GetMapping("/courses")
+ public List> index(@RequestParam HashMap params)
+ throws QueryHandlerExecutionError {
+ BackofficeCoursesResponse courses = ask(
+ new SearchBackofficeCoursesByCriteriaQuery(
+ parseFilters(params),
+ Optional.ofNullable((String) params.get("order_by")),
+ Optional.ofNullable((String) params.get("order")),
+ Optional.ofNullable((Integer) params.get("limit")),
+ Optional.ofNullable((Integer) params.get("offset"))
+ )
+ );
+
+ return courses
+ .courses()
+ .stream()
+ .map(response ->
+ new HashMap() {
+ {
+ put("id", response.id());
+ put("name", response.name());
+ put("duration", response.duration());
+ }
+ }
+ )
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+
+ private List> parseFilters(HashMap params) {
+ int maxParams = params.size();
+
+ List> filters = new ArrayList<>();
+
+ for (int possibleFilterKey = 0; possibleFilterKey < maxParams; possibleFilterKey++) {
+ if (params.containsKey(String.format("filters[%s][field]", possibleFilterKey))) {
+ int key = possibleFilterKey;
+
+ filters.add(
+ new HashMap() {
+ {
+ put("field", (String) params.get(String.format("filters[%s][field]", key)));
+ put("operator", (String) params.get(String.format("filters[%s][operator]", key)));
+ put("value", (String) params.get(String.format("filters[%s][value]", key)));
+ }
+ }
+ );
+ }
+ }
+
+ return filters;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetController.java
new file mode 100644
index 0000000..bef9ec0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetController.java
@@ -0,0 +1,34 @@
+package tv.codely.apps.backoffice.backend.controller.health_check;
+
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class HealthCheckGetController extends ApiController {
+
+ public HealthCheckGetController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @GetMapping("/health-check")
+ public HashMap index() {
+ HashMap status = new HashMap<>();
+ status.put("application", "backoffice_backend");
+ status.put("status", "ok");
+
+ return status;
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/middleware/BasicHttpAuthMiddleware.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/middleware/BasicHttpAuthMiddleware.java
new file mode 100644
index 0000000..2ce6a90
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/backend/middleware/BasicHttpAuthMiddleware.java
@@ -0,0 +1,77 @@
+package tv.codely.apps.backoffice.backend.middleware;
+
+import java.io.IOException;
+import java.util.Base64;
+
+import jakarta.servlet.*;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import tv.codely.backoffice.auth.application.authenticate.AuthenticateUserCommand;
+import tv.codely.backoffice.auth.domain.InvalidAuthCredentials;
+import tv.codely.backoffice.auth.domain.InvalidAuthUsername;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+
+public final class BasicHttpAuthMiddleware implements Filter {
+
+ private final CommandBus bus;
+
+ public BasicHttpAuthMiddleware(CommandBus bus) {
+ this.bus = bus;
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ String authorizationHeader = ((HttpServletRequest) request).getHeader("authorization");
+
+ if (hasIntroducedCredentials(authorizationHeader)) {
+ authenticate(authorizationHeader, chain, request, response);
+ } else {
+ askForCredentials(response);
+ }
+ }
+
+ private boolean hasIntroducedCredentials(String authorizationHeader) {
+ return null != authorizationHeader;
+ }
+
+ private void authenticate(
+ String authorizationHeader,
+ FilterChain chain,
+ ServletRequest request,
+ ServletResponse response
+ ) throws IOException, ServletException {
+ String[] auth = decodeAuth(authorizationHeader);
+ String user = auth[0];
+ String pass = auth[1];
+
+ try {
+ bus.dispatch(new AuthenticateUserCommand(user, pass));
+
+ request.setAttribute("authentication_username", user);
+
+ chain.doFilter(request, response);
+ } catch (InvalidAuthUsername | InvalidAuthCredentials | CommandHandlerExecutionError error) {
+ setInvalidCredentials(response);
+ }
+ }
+
+ private String[] decodeAuth(String authString) {
+ return new String(Base64.getDecoder().decode(authString.split("\\s+")[1])).split(":");
+ }
+
+ private void setInvalidCredentials(ServletResponse response) {
+ HttpServletResponse httpServletResponse = (HttpServletResponse) response;
+ httpServletResponse.reset();
+ httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ private void askForCredentials(ServletResponse response) {
+ HttpServletResponse httpServletResponse = (HttpServletResponse) response;
+ httpServletResponse.reset();
+ httpServletResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ httpServletResponse.setHeader("WWW-Authenticate", "Basic realm=\"CodelyTV\"");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/BackofficeFrontendApplication.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/BackofficeFrontendApplication.java
new file mode 100644
index 0000000..ae42787
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/BackofficeFrontendApplication.java
@@ -0,0 +1,24 @@
+package tv.codely.apps.backoffice.frontend;
+
+import java.util.HashMap;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+
+import tv.codely.shared.domain.Service;
+
+@SpringBootApplication(exclude = HibernateJpaAutoConfiguration.class)
+@ComponentScan(
+ includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Service.class),
+ value = { "tv.codely.shared", "tv.codely.backoffice", "tv.codely.mooc", "tv.codely.apps.backoffice.frontend" }
+)
+public class BackofficeFrontendApplication {
+
+ public static HashMap> commands() {
+ return new HashMap>() {
+ {}
+ };
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendServerPortCustomizer.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendServerPortCustomizer.java
new file mode 100644
index 0000000..6cfbd81
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendServerPortCustomizer.java
@@ -0,0 +1,28 @@
+package tv.codely.apps.backoffice.frontend.config;
+
+import org.springframework.boot.web.server.ConfigurableWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+
+@Component
+public final class BackofficeFrontendServerPortCustomizer
+ implements WebServerFactoryCustomizer {
+
+ private final Parameter param;
+
+ public BackofficeFrontendServerPortCustomizer(Parameter param) {
+ this.param = param;
+ }
+
+ @Override
+ public void customize(ConfigurableWebServerFactory factory) {
+ try {
+ factory.setPort(param.getInt("BACKOFFICE_FRONTEND_SERVER_PORT"));
+ } catch (ParameterNotExist parameterNotExist) {
+ parameterNotExist.printStackTrace();
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendWebConfig.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendWebConfig.java
new file mode 100644
index 0000000..31405d6
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/config/BackofficeFrontendWebConfig.java
@@ -0,0 +1,46 @@
+package tv.codely.apps.backoffice.frontend.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.ViewResolver;
+import org.springframework.web.servlet.config.annotation.EnableWebMvc;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.ViewResolverRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;
+import org.springframework.web.servlet.view.freemarker.FreeMarkerViewResolver;
+
+@Configuration
+@EnableWebMvc
+public class BackofficeFrontendWebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void configureViewResolvers(ViewResolverRegistry registry) {
+ registry.freeMarker();
+ }
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ registry.addResourceHandler("/**").addResourceLocations("classpath:/backoffice_frontend/public/");
+ }
+
+ @Bean
+ public ViewResolver getViewResolver() {
+ FreeMarkerViewResolver resolver = new FreeMarkerViewResolver();
+ resolver.setCache(false);
+ resolver.setSuffix(".ftl");
+ return resolver;
+ }
+
+ @Bean
+ public FreeMarkerConfigurer freeMarkerConfigurer() {
+ FreeMarkerConfigurer configurer = new FreeMarkerConfigurer();
+ configurer.setTemplateLoaderPath("classpath:/backoffice_frontend/templates/");
+ configurer.setDefaultEncoding("UTF-8");
+ // configurer.setFreemarkerVariables(new HashMap() {{
+ // put("flash", new Flash());
+ // }});
+
+ return configurer;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesGetWebController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesGetWebController.java
new file mode 100644
index 0000000..95ff91c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesGetWebController.java
@@ -0,0 +1,48 @@
+package tv.codely.apps.backoffice.frontend.controller.courses;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.servlet.ModelAndView;
+
+import tv.codely.mooc.courses_counter.application.find.CoursesCounterResponse;
+import tv.codely.mooc.courses_counter.application.find.FindCoursesCounterQuery;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.domain.bus.query.QueryHandlerExecutionError;
+
+@Controller
+public final class CoursesGetWebController {
+
+ private final QueryBus bus;
+
+ public CoursesGetWebController(QueryBus bus) {
+ this.bus = bus;
+ }
+
+ @GetMapping("/courses")
+ public ModelAndView index(
+ @ModelAttribute("inputs") HashMap inputs,
+ @ModelAttribute("errors") HashMap> errors
+ ) throws QueryHandlerExecutionError {
+ CoursesCounterResponse counterResponse = bus.ask(new FindCoursesCounterQuery());
+
+ return new ModelAndView(
+ "pages/courses/courses",
+ new HashMap() {
+ {
+ put("title", "Courses");
+ put("description", "Courses CodelyTV - Backoffice");
+ put("courses_counter", counterResponse.total());
+ put("inputs", inputs);
+ put("errors", errors);
+ put("generated_uuid", UUID.randomUUID().toString());
+ }
+ }
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesPostWebController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesPostWebController.java
new file mode 100644
index 0000000..927dd41
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/courses/CoursesPostWebController.java
@@ -0,0 +1,67 @@
+package tv.codely.apps.backoffice.frontend.controller.courses;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+import org.springframework.web.servlet.view.RedirectView;
+
+import tv.codely.mooc.courses.application.create.CreateCourseCommand;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+import tv.codely.shared.infrastructure.validation.ValidationResponse;
+import tv.codely.shared.infrastructure.validation.Validator;
+
+@Controller
+public final class CoursesPostWebController {
+
+ private final CommandBus bus;
+ private final HashMap rules = new HashMap() {
+ {
+ put("id", "required|not_empty|uuid");
+ put("name", "required|not_empty|string");
+ put("duration", "required|not_empty|string");
+ }
+ };
+
+ public CoursesPostWebController(CommandBus bus) {
+ this.bus = bus;
+ }
+
+ @PostMapping(value = "/courses", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
+ public RedirectView index(@RequestParam HashMap request, RedirectAttributes attributes)
+ throws Exception {
+ ValidationResponse validationResponse = Validator.validate(request, rules);
+
+ return validationResponse.hasErrors()
+ ? redirectWithErrors(validationResponse, request, attributes)
+ : createCourse(request);
+ }
+
+ private RedirectView redirectWithErrors(
+ ValidationResponse validationResponse,
+ HashMap request,
+ RedirectAttributes attributes
+ ) {
+ attributes.addFlashAttribute("errors", validationResponse.errors());
+ attributes.addFlashAttribute("inputs", request);
+
+ return new RedirectView("/courses");
+ }
+
+ private RedirectView createCourse(HashMap request) throws CommandHandlerExecutionError {
+ bus.dispatch(
+ new CreateCourseCommand(
+ request.get("id").toString(),
+ request.get("name").toString(),
+ request.get("duration").toString()
+ )
+ );
+
+ return new RedirectView("/courses");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetController.java
new file mode 100644
index 0000000..9c06c15
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetController.java
@@ -0,0 +1,19 @@
+package tv.codely.apps.backoffice.frontend.controller.health_check;
+
+import java.util.HashMap;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public final class HealthCheckGetController {
+
+ @GetMapping("/health-check")
+ public HashMap index() {
+ HashMap status = new HashMap<>();
+ status.put("application", "backoffice_frontend");
+ status.put("status", "ok");
+
+ return status;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/home/HomeGetWebController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/home/HomeGetWebController.java
new file mode 100644
index 0000000..254fba9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/backoffice/frontend/controller/home/HomeGetWebController.java
@@ -0,0 +1,25 @@
+package tv.codely.apps.backoffice.frontend.controller.home;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.servlet.ModelAndView;
+
+@Controller
+public final class HomeGetWebController {
+
+ @GetMapping("/")
+ public ModelAndView index() {
+ return new ModelAndView(
+ "pages/home",
+ new HashMap() {
+ {
+ put("title", "Welcome");
+ put("description", "CodelyTV - Backoffice");
+ }
+ }
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/MoocBackendApplication.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/MoocBackendApplication.java
new file mode 100644
index 0000000..b9e0448
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/MoocBackendApplication.java
@@ -0,0 +1,29 @@
+package tv.codely.apps.mooc.backend;
+
+import java.util.HashMap;
+
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+
+import tv.codely.apps.mooc.backend.command.ConsumeMySqlDomainEventsCommand;
+import tv.codely.apps.mooc.backend.command.ConsumeRabbitMqDomainEventsCommand;
+import tv.codely.shared.domain.Service;
+
+@SpringBootApplication(exclude = HibernateJpaAutoConfiguration.class)
+@ComponentScan(
+ includeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Service.class),
+ value = { "tv.codely.shared", "tv.codely.mooc", "tv.codely.apps.mooc.backend" }
+)
+public class MoocBackendApplication {
+
+ public static HashMap> commands() {
+ return new HashMap>() {
+ {
+ put("domain-events:mysql:consume", ConsumeMySqlDomainEventsCommand.class);
+ put("domain-events:rabbitmq:consume", ConsumeRabbitMqDomainEventsCommand.class);
+ }
+ };
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeMySqlDomainEventsCommand.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeMySqlDomainEventsCommand.java
new file mode 100644
index 0000000..78eaf41
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeMySqlDomainEventsCommand.java
@@ -0,0 +1,18 @@
+package tv.codely.apps.mooc.backend.command;
+
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlDomainEventsConsumer;
+import tv.codely.shared.infrastructure.cli.ConsoleCommand;
+
+public final class ConsumeMySqlDomainEventsCommand extends ConsoleCommand {
+
+ private final MySqlDomainEventsConsumer consumer;
+
+ public ConsumeMySqlDomainEventsCommand(MySqlDomainEventsConsumer consumer) {
+ this.consumer = consumer;
+ }
+
+ @Override
+ public void execute(String[] args) {
+ consumer.consume();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeRabbitMqDomainEventsCommand.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeRabbitMqDomainEventsCommand.java
new file mode 100644
index 0000000..993dcd5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/command/ConsumeRabbitMqDomainEventsCommand.java
@@ -0,0 +1,18 @@
+package tv.codely.apps.mooc.backend.command;
+
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqDomainEventsConsumer;
+import tv.codely.shared.infrastructure.cli.ConsoleCommand;
+
+public final class ConsumeRabbitMqDomainEventsCommand extends ConsoleCommand {
+
+ private final RabbitMqDomainEventsConsumer consumer;
+
+ public ConsumeRabbitMqDomainEventsCommand(RabbitMqDomainEventsConsumer consumer) {
+ this.consumer = consumer;
+ }
+
+ @Override
+ public void execute(String[] args) {
+ consumer.consume();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerConfiguration.java
new file mode 100644
index 0000000..9974cdd
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerConfiguration.java
@@ -0,0 +1,27 @@
+package tv.codely.apps.mooc.backend.config;
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
+
+import tv.codely.shared.infrastructure.spring.ApiExceptionMiddleware;
+
+@Configuration
+public class MoocBackendServerConfiguration {
+
+ private final RequestMappingHandlerMapping mapping;
+
+ public MoocBackendServerConfiguration(RequestMappingHandlerMapping mapping) {
+ this.mapping = mapping;
+ }
+
+ @Bean
+ public FilterRegistrationBean apiExceptionMiddleware() {
+ FilterRegistrationBean registrationBean = new FilterRegistrationBean<>();
+
+ registrationBean.setFilter(new ApiExceptionMiddleware(mapping));
+
+ return registrationBean;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerPortCustomizer.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerPortCustomizer.java
new file mode 100644
index 0000000..3d61c27
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/config/MoocBackendServerPortCustomizer.java
@@ -0,0 +1,27 @@
+package tv.codely.apps.mooc.backend.config;
+
+import org.springframework.boot.web.server.ConfigurableWebServerFactory;
+import org.springframework.boot.web.server.WebServerFactoryCustomizer;
+import org.springframework.stereotype.Component;
+
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+
+@Component
+public final class MoocBackendServerPortCustomizer implements WebServerFactoryCustomizer {
+
+ private final Parameter param;
+
+ public MoocBackendServerPortCustomizer(Parameter param) {
+ this.param = param;
+ }
+
+ @Override
+ public void customize(ConfigurableWebServerFactory factory) {
+ try {
+ factory.setPort(param.getInt("MOOC_BACKEND_SERVER_PORT"));
+ } catch (ParameterNotExist parameterNotExist) {
+ parameterNotExist.printStackTrace();
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CourseGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CourseGetController.java
new file mode 100644
index 0000000..838783b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CourseGetController.java
@@ -0,0 +1,54 @@
+package tv.codely.apps.mooc.backend.controller.courses;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.mooc.courses.application.CourseResponse;
+import tv.codely.mooc.courses.application.find.FindCourseQuery;
+import tv.codely.mooc.courses.domain.CourseNotExist;
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.domain.bus.query.QueryHandlerExecutionError;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class CourseGetController extends ApiController {
+
+ public CourseGetController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @GetMapping("/courses/{id}")
+ public ResponseEntity> index(@PathVariable String id)
+ throws QueryHandlerExecutionError {
+ CourseResponse course = ask(new FindCourseQuery(id));
+
+ return ResponseEntity
+ .ok()
+ .body(
+ new HashMap() {
+ {
+ put("id", course.id());
+ put("name", course.name());
+ put("duration", course.duration());
+ }
+ }
+ );
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return new HashMap, HttpStatus>() {
+ {
+ put(CourseNotExist.class, HttpStatus.NOT_FOUND);
+ }
+ };
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CoursesPutController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CoursesPutController.java
new file mode 100644
index 0000000..54bc204
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses/CoursesPutController.java
@@ -0,0 +1,60 @@
+package tv.codely.apps.mooc.backend.controller.courses;
+
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.mooc.courses.application.create.CreateCourseCommand;
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class CoursesPutController extends ApiController {
+
+ public CoursesPutController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @PutMapping(value = "/courses/{id}")
+ public ResponseEntity index(@PathVariable String id, @RequestBody Request request)
+ throws CommandHandlerExecutionError {
+ dispatch(new CreateCourseCommand(id, request.name(), request.duration()));
+
+ return new ResponseEntity<>(HttpStatus.CREATED);
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+}
+
+final class Request {
+
+ private String name;
+ private String duration;
+
+ public void setDuration(String duration) {
+ this.duration = duration;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ String name() {
+ return name;
+ }
+
+ String duration() {
+ return duration;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetController.java
new file mode 100644
index 0000000..88a932f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetController.java
@@ -0,0 +1,39 @@
+package tv.codely.apps.mooc.backend.controller.courses_counter;
+
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.mooc.courses_counter.application.find.CoursesCounterResponse;
+import tv.codely.mooc.courses_counter.application.find.FindCoursesCounterQuery;
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.domain.bus.query.QueryHandlerExecutionError;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class CoursesCounterGetController extends ApiController {
+
+ public CoursesCounterGetController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @GetMapping("/courses-counter")
+ public HashMap index() throws QueryHandlerExecutionError {
+ CoursesCounterResponse response = ask(new FindCoursesCounterQuery());
+
+ return new HashMap() {
+ {
+ put("total", response.total());
+ }
+ };
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetController.java
new file mode 100644
index 0000000..43461f9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetController.java
@@ -0,0 +1,34 @@
+package tv.codely.apps.mooc.backend.controller.health_check;
+
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class HealthCheckGetController extends ApiController {
+
+ public HealthCheckGetController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @GetMapping("/health-check")
+ public HashMap index() {
+ HashMap status = new HashMap<>();
+ status.put("application", "mooc_backend");
+ status.put("status", "ok");
+
+ return status;
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutController.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutController.java
new file mode 100644
index 0000000..3fbcd68
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/main/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutController.java
@@ -0,0 +1,36 @@
+package tv.codely.apps.mooc.backend.controller.notifications;
+
+import java.util.HashMap;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import tv.codely.mooc.notifications.application.send_new_courses_newsletter.SendNewCoursesNewsletterCommand;
+import tv.codely.shared.domain.DomainError;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+import tv.codely.shared.domain.bus.query.QueryBus;
+import tv.codely.shared.infrastructure.spring.ApiController;
+
+@RestController
+public final class NewsletterNotificationPutController extends ApiController {
+
+ public NewsletterNotificationPutController(QueryBus queryBus, CommandBus commandBus) {
+ super(queryBus, commandBus);
+ }
+
+ @PutMapping(value = "/newsletter/{id}")
+ public ResponseEntity index(@PathVariable String id) throws CommandHandlerExecutionError {
+ dispatch(new SendNewCoursesNewsletterCommand(id));
+
+ return new ResponseEntity<>(HttpStatus.CREATED);
+ }
+
+ @Override
+ public HashMap, HttpStatus> errorMapping() {
+ return null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/resources/log4j2.properties b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/resources/log4j2.properties
new file mode 100644
index 0000000..98427f2
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/resources/log4j2.properties
@@ -0,0 +1,33 @@
+name = CodelyTvJavaDddExample
+property.filename = logs
+appenders = console, file
+
+status = warn
+
+appender.console.name = CONSOLE
+appender.console.type = CONSOLE
+appender.console.target = SYSTEM_OUT
+
+appender.console.layout.type = PatternLayout
+appender.console.layout.pattern = [%level] [%date{HH:mm:ss.SSS}] [%class{0}#%method:%line] %message \(%mdc\) %n%throwable
+appender.console.filter.threshold.type = ThresholdFilter
+appender.console.filter.threshold.level = info
+
+appender.file.type = File
+appender.file.name = LOGFILE
+appender.file.fileName = var/log/java-ddd-example-test.log
+appender.file.logstash.type = LogstashLayout
+appender.file.logstash.dateTimeFormatPattern = yyyy-MM-dd'T'HH:mm:ss.SSSZZZ
+appender.file.logstash.eventTemplateUri = classpath:LogstashJsonEventLayoutV1.json
+appender.file.logstash.prettyPrintEnabled = false
+appender.file.logstash.stackTraceEnabled = true
+
+loggers = file
+logger.file.name = tv.codely.java_ddd_example
+logger.file.level = info
+logger.file.appenderRefs = file
+logger.file.appenderRef.file.ref = LOGFILE
+
+rootLogger.level = info
+rootLogger.appenderRefs = stdout
+rootLogger.appenderRef.stdout.ref = CONSOLE
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/ApplicationTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/ApplicationTestCase.java
new file mode 100644
index 0000000..d356818
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/ApplicationTestCase.java
@@ -0,0 +1,67 @@
+package tv.codely.apps;
+
+import static org.springframework.http.MediaType.APPLICATION_JSON;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.Arrays;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultMatcher;
+
+import tv.codely.shared.domain.bus.event.DomainEvent;
+import tv.codely.shared.domain.bus.event.EventBus;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+public abstract class ApplicationTestCase {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private EventBus eventBus;
+
+ protected void assertResponse(String endpoint, Integer expectedStatusCode, String expectedResponse) throws Exception {
+ ResultMatcher response = expectedResponse.isEmpty() ? content().string("") : content().json(expectedResponse);
+
+ mockMvc.perform(get(endpoint)).andExpect(status().is(expectedStatusCode)).andExpect(response);
+ }
+
+ protected void assertResponse(
+ String endpoint,
+ Integer expectedStatusCode,
+ String expectedResponse,
+ HttpHeaders headers
+ ) throws Exception {
+ ResultMatcher response = expectedResponse.isEmpty() ? content().string("") : content().json(expectedResponse);
+
+ mockMvc.perform(get(endpoint).headers(headers)).andExpect(status().is(expectedStatusCode)).andExpect(response);
+ }
+
+ protected void assertRequestWithBody(String method, String endpoint, String body, Integer expectedStatusCode)
+ throws Exception {
+ mockMvc
+ .perform(request(HttpMethod.valueOf(method), endpoint).content(body).contentType(APPLICATION_JSON))
+ .andExpect(status().is(expectedStatusCode))
+ .andExpect(content().string(""));
+ }
+
+ protected void assertRequest(String method, String endpoint, Integer expectedStatusCode) throws Exception {
+ mockMvc
+ .perform(request(HttpMethod.valueOf(method), endpoint))
+ .andExpect(status().is(expectedStatusCode))
+ .andExpect(content().string(""));
+ }
+
+ protected void givenISendEventsToTheBus(DomainEvent... domainEvents) {
+ eventBus.publish(Arrays.asList(domainEvents));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/BackofficeApplicationTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/BackofficeApplicationTestCase.java
new file mode 100644
index 0000000..195b553
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/BackofficeApplicationTestCase.java
@@ -0,0 +1,8 @@
+package tv.codely.apps.backoffice;
+
+import org.springframework.transaction.annotation.Transactional;
+
+import tv.codely.apps.ApplicationTestCase;
+
+@Transactional("backoffice-transaction_manager")
+public abstract class BackofficeApplicationTestCase extends ApplicationTestCase {}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetControllerShould.java
new file mode 100644
index 0000000..add71d8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/backend/controller/health_check/HealthCheckGetControllerShould.java
@@ -0,0 +1,38 @@
+package tv.codely.apps.backoffice.backend.controller.health_check;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+
+import tv.codely.apps.ApplicationTestCase;
+
+final class HealthCheckGetControllerShould extends ApplicationTestCase {
+
+ @Test
+ void check_the_app_is_working_ok_with_valid_credentials() throws Exception {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBasicAuth("javi", "barbitas");
+
+ assertResponse("/health-check", 200, "{'application':'backoffice_backend','status':'ok'}", headers);
+ }
+
+ @Test
+ void not_check_the_app_is_working_ok_with_invalid_credentials() throws Exception {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBasicAuth("tipo_de_incognito", "homer.sampson");
+
+ assertResponse("/health-check", 403, "", headers);
+ }
+
+ @Test
+ void not_check_the_app_is_working_ok_with_invalid_credentials_of_an_existing_user() throws Exception {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBasicAuth("rafa", "incorrect.password");
+
+ assertResponse("/health-check", 403, "", headers);
+ }
+
+ @Test
+ void not_check_the_app_is_working_ok_with_no_credentials() throws Exception {
+ assertResponse("/health-check", 401, "");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetControllerShould.java
new file mode 100644
index 0000000..c5a9747
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/backoffice/frontend/controller/health_check/HealthCheckGetControllerShould.java
@@ -0,0 +1,13 @@
+package tv.codely.apps.backoffice.frontend.controller.health_check;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.ApplicationTestCase;
+
+final class HealthCheckGetControllerShould extends ApplicationTestCase {
+
+ @Test
+ void check_the_app_is_working_ok() throws Exception {
+ assertResponse("/health-check", 200, "{'application':'backoffice_frontend','status':'ok'}");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/MoocApplicationTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/MoocApplicationTestCase.java
new file mode 100644
index 0000000..90dc231
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/MoocApplicationTestCase.java
@@ -0,0 +1,8 @@
+package tv.codely.apps.mooc;
+
+import org.springframework.transaction.annotation.Transactional;
+
+import tv.codely.apps.ApplicationTestCase;
+
+@Transactional("mooc-transaction_manager")
+public abstract class MoocApplicationTestCase extends ApplicationTestCase {}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CourseGetControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CourseGetControllerShould.java
new file mode 100644
index 0000000..0ebbcc4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CourseGetControllerShould.java
@@ -0,0 +1,31 @@
+package tv.codely.apps.mooc.backend.controller.courses;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.mooc.MoocApplicationTestCase;
+
+final class CourseGetControllerShould extends MoocApplicationTestCase {
+
+ @Test
+ void find_an_existing_course() throws Exception {
+ String id = "99ad55f5-6eab-4d73-b383-c63268e251e8";
+ String body = "{\"name\": \"The best course\", \"duration\": \"5 hours\"}";
+
+ givenThereIsACourse(id, body);
+
+ assertResponse(String.format("/courses/%s", id), 200, body);
+ }
+
+ @Test
+ void no_find_a_non_existing_course() throws Exception {
+ String id = "4ecc0cb3-05b2-4238-b7e1-1fbb0d5d3661";
+ String body =
+ "{\"error_code\": \"course_not_exist\", \"message\": \"The course <4ecc0cb3-05b2-4238-b7e1-1fbb0d5d3661> doesn't exist\"}";
+
+ assertResponse(String.format("/courses/%s", id), 404, body);
+ }
+
+ private void givenThereIsACourse(String id, String body) throws Exception {
+ assertRequestWithBody("PUT", String.format("/courses/%s", id), body, 201);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CoursesPutControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CoursesPutControllerShould.java
new file mode 100644
index 0000000..9dcbe3d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses/CoursesPutControllerShould.java
@@ -0,0 +1,18 @@
+package tv.codely.apps.mooc.backend.controller.courses;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.mooc.MoocApplicationTestCase;
+
+public final class CoursesPutControllerShould extends MoocApplicationTestCase {
+
+ @Test
+ void create_a_valid_non_existing_course() throws Exception {
+ assertRequestWithBody(
+ "PUT",
+ "/courses/1aab45ba-3c7a-4344-8936-78466eca77fa",
+ "{\"name\": \"The best course\", \"duration\": \"5 hours\"}",
+ 201
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetControllerShould.java
new file mode 100644
index 0000000..2927678
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/courses_counter/CoursesCounterGetControllerShould.java
@@ -0,0 +1,46 @@
+package tv.codely.apps.mooc.backend.controller.courses_counter;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.mooc.MoocApplicationTestCase;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+public final class CoursesCounterGetControllerShould extends MoocApplicationTestCase {
+
+ @Test
+ void get_the_counter_with_one_course() throws Exception {
+ givenISendEventsToTheBus(
+ new CourseCreatedDomainEvent("8f34bc99-e0e2-4296-a008-75f51f03aeb4", "DDD en Java", "7 days")
+ );
+
+ assertResponse("/courses-counter", 200, "{'total': 1}");
+ }
+
+ @Test
+ void get_the_counter_with_more_than_one_course() throws Exception {
+ givenISendEventsToTheBus(
+ new CourseCreatedDomainEvent("8f34bc99-e0e2-4296-a008-75f51f03aeb4", "DDD en Java", "7 days"),
+ new CourseCreatedDomainEvent("3642f700-868a-4778-9317-a2d542d01785", "DDD en PHP", "6 days"),
+ new CourseCreatedDomainEvent("92dd8402-69f3-4900-b569-3f2c2797065f", "DDD en Cobol", "10 years")
+ );
+
+ assertResponse("/courses-counter", 200, "{'total': 3}");
+ }
+
+ @Test
+ void get_the_counter_with_more_than_one_course_having_duplicated_events() throws Exception {
+ givenISendEventsToTheBus(
+ new CourseCreatedDomainEvent("8f34bc99-e0e2-4296-a008-75f51f03aeb4", "DDD en Java", "7 days"),
+ new CourseCreatedDomainEvent("8f34bc99-e0e2-4296-a008-75f51f03aeb4", "DDD en Java", "7 days"),
+ new CourseCreatedDomainEvent("8f34bc99-e0e2-4296-a008-75f51f03aeb4", "DDD en Java", "7 days"),
+ new CourseCreatedDomainEvent("3642f700-868a-4778-9317-a2d542d01785", "DDD en PHP", "6 days"),
+ new CourseCreatedDomainEvent("3642f700-868a-4778-9317-a2d542d01785", "DDD en PHP", "6 days"),
+ new CourseCreatedDomainEvent("3642f700-868a-4778-9317-a2d542d01785", "DDD en PHP", "6 days"),
+ new CourseCreatedDomainEvent("3642f700-868a-4778-9317-a2d542d01785", "DDD en PHP", "6 days"),
+ new CourseCreatedDomainEvent("92dd8402-69f3-4900-b569-3f2c2797065f", "DDD en Cobol", "10 years"),
+ new CourseCreatedDomainEvent("92dd8402-69f3-4900-b569-3f2c2797065f", "DDD en Cobol", "10 years")
+ );
+
+ assertResponse("/courses-counter", 200, "{'total': 3}");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetControllerShould.java
new file mode 100644
index 0000000..94069bf
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/health_check/HealthCheckGetControllerShould.java
@@ -0,0 +1,13 @@
+package tv.codely.apps.mooc.backend.controller.health_check;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.mooc.MoocApplicationTestCase;
+
+final class HealthCheckGetControllerShould extends MoocApplicationTestCase {
+
+ @Test
+ void check_the_app_is_working_ok() throws Exception {
+ assertResponse("/health-check", 200, "{'application':'mooc_backend','status':'ok'}");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutControllerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutControllerShould.java
new file mode 100644
index 0000000..8f011e9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/apps/test/tv/codely/apps/mooc/backend/controller/notifications/NewsletterNotificationPutControllerShould.java
@@ -0,0 +1,13 @@
+package tv.codely.apps.mooc.backend.controller.notifications;
+
+import org.junit.jupiter.api.Test;
+
+import tv.codely.apps.mooc.MoocApplicationTestCase;
+
+final class NewsletterNotificationPutControllerShould extends MoocApplicationTestCase {
+
+ @Test
+ void create_a_valid_non_existing_course() throws Exception {
+ assertRequest("PUT", "/newsletter/6eebbe60-50e7-400a-810c-3e0af0943ee6", 201);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/build.gradle b/04-eventual_consistency/2-ensure_event_is_always_published/build.gradle
new file mode 100644
index 0000000..508fb39
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/build.gradle
@@ -0,0 +1,171 @@
+// Main project (located in apps/)
+buildscript {
+ repositories {
+ mavenCentral()
+ }
+ dependencies {
+ classpath('org.springframework.boot:spring-boot-gradle-plugin:3.1.5')
+ }
+}
+
+plugins {
+ id "com.diffplug.spotless" version "6.22.0"
+}
+
+spotless {
+ java {
+ prettier(['prettier': '2.8.8', 'prettier-plugin-java': '2.2.0'])
+ .config(['parser': 'java', 'useTabs': true, 'printWidth': 120])
+
+ importOrder('\\#', 'java', '', 'tv.codely')
+ removeUnusedImports()
+
+ endWithNewline()
+
+ formatAnnotations()
+ }
+}
+
+// Common for all projects
+allprojects {
+ apply plugin: 'java'
+ apply plugin: 'io.spring.dependency-management'
+ apply plugin: 'org.springframework.boot'
+
+ java {
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
+ }
+
+
+ repositories {
+ mavenCentral()
+ maven { url 'https://repo.spring.io/milestone' }
+ }
+
+ ext {
+ set('springCloudVersion', "Hoxton.M3")
+ set('elasticsearchVersion', '6.8.4')
+ }
+
+ dependencies {
+ // Prod
+ implementation 'org.apache.logging.log4j:log4j-core:2.21.1'
+ implementation 'org.apache.logging.log4j:log4j-api:2.21.1'
+ implementation 'com.vlkan.log4j2:log4j2-logstash-layout:1.0.5'
+ implementation 'io.github.cdimascio:dotenv-java:3.0.0'
+ implementation 'org.hibernate.orm:hibernate-core:6.3.1.Final'
+ implementation 'jakarta.persistence:jakarta.persistence-api:3.2.0-B01'
+ implementation 'org.springframework:spring-orm:6.0.13'
+ implementation 'org.springframework:spring-context-support:6.0.13'
+ implementation 'org.apache.tomcat:tomcat-dbcp:10.1.15'
+ implementation 'com.sun.xml.bind:jaxb-impl:4.0.4'
+ implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.1'
+ implementation 'org.freemarker:freemarker-gae:2.3.32'
+ implementation 'org.reflections:reflections:0.10.2'
+ implementation 'com.google.guava:guava:31.0.1-jre'
+ implementation 'org.springframework.boot:spring-boot-starter-amqp'
+ implementation "org.elasticsearch.client:elasticsearch-rest-client:${elasticsearchVersion}"
+ implementation "org.elasticsearch.client:elasticsearch-rest-high-level-client:${elasticsearchVersion}"
+ implementation 'mysql:mysql-connector-java:8.0.28'
+
+ // Test
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.5.2'
+ testImplementation 'org.mockito:mockito-core:3.3.3'
+ testImplementation 'com.github.javafaker:javafaker:1.0.1'
+ testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.5.2'
+ }
+
+ dependencyManagement {
+ imports {
+ mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
+ }
+ }
+
+ test {
+ useJUnitPlatform()
+
+ testLogging {
+ events "passed", "skipped", "failed"
+ }
+ }
+
+ task view_paths {
+ doLast { task ->
+ println "$task.project.name"
+ println "------------------"
+ println "Main: $sourceSets.main.java.srcDirTrees"
+ println " Resources: $sourceSets.main.resources.srcDirTrees"
+ println "Tests: $sourceSets.test.java.srcDirTrees"
+ println " Resources: $sourceSets.test.resources.srcDirTrees"
+ }
+ }
+}
+
+// All subprojects (located in src/*)
+subprojects {
+ group = "tv.codely.${rootProject.name}"
+
+ sourceSets {
+ main {
+ java { srcDirs = ['main'] }
+ resources { srcDirs = ['main/resources'] }
+ }
+
+ test {
+ java { srcDirs = ['test'] }
+ resources { srcDirs = ['test/resources'] }
+ }
+ }
+
+ dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
+
+ testImplementation rootProject.sourceSets.main.output
+ testImplementation 'org.springframework.boot:spring-boot-starter-test:3.1.5'
+
+ if (project.name != "shared") {
+ implementation project(":shared")
+ testImplementation project(":shared").sourceSets.test.output
+ }
+ }
+
+ bootJar {
+ enabled = false
+ }
+
+ jar {
+ enabled = true
+ }
+}
+
+
+sourceSets {
+ main {
+ java { srcDirs = ['apps/main'] }
+ resources { srcDirs = ['apps/main/resources'] }
+ }
+
+ test {
+ java { srcDirs = ['apps/test'] }
+ resources { srcDirs = ['apps/test/resources'] }
+ }
+}
+
+bootJar {
+ archiveBaseName.set('java-ddd-example')
+ archiveVersion.set('0.0.1')
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+
+dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
+
+ implementation project(":shared")
+ implementation project(":backoffice")
+ implementation project(":mooc")
+
+ testImplementation 'org.springframework.boot:spring-boot-starter-test:3.1.5'
+ testImplementation project(":shared").sourceSets.test.output
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/doc/endpoints/backoffice_frontend.http b/04-eventual_consistency/2-ensure_event_is_always_published/doc/endpoints/backoffice_frontend.http
new file mode 100644
index 0000000..119764f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/doc/endpoints/backoffice_frontend.http
@@ -0,0 +1,80 @@
+# ELASTIC - Search
+POST localhost:9200/backoffice_courses/_search
+Content-Type: application/json
+
+{
+ "query": {
+ "term": {
+ "name": "git avanzado"
+ }
+ }
+}
+
+###
+
+# ELASTIC - debug
+POST localhost:9200/backoffice_courses/_search
+Content-Type: application/json
+
+{
+ "from": 0,
+ "size": 1000,
+ "query": {
+ "bool": {
+ "must": [
+ {
+ "term": {
+ "name": {
+ "value": "pepe2"
+ }
+ }
+ }
+ ],
+ "adjust_pure_negative": true,
+ "boost": 1.0
+ }
+ }
+}
+
+###
+# ELASTIC - Search
+POST localhost:9200/backoffice_courses/_search
+Content-Type: application/json
+
+###
+# ELASTIC - Mapping
+GET localhost:9200/backoffice_courses/_mapping
+Content-Type: application/json
+
+###
+# ELASTIC - Change mapping
+PUT localhost:9200/backoffice_courses/_mapping/_doc
+Content-Type: application/json
+
+{
+ "properties": {
+ "name": {
+ "type": "text",
+ "fielddata": true
+ }
+ }
+}
+
+###
+# ELASTIC - DELETE
+DELETE localhost:9200/backoffice_courses
+
+###
+
+PUT localhost:9200/backoffice_courses/_settings
+Content-Type: application/json
+
+{
+ "index": {
+ "blocks": {
+ "read_only_allow_delete": "false"
+ }
+ }
+}
+
+###
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.ci.yml b/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.ci.yml
new file mode 100755
index 0000000..654ffd1
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.ci.yml
@@ -0,0 +1,55 @@
+version: '3'
+
+services:
+ shared_mysql:
+ container_name: codely-java_ddd_example-mysql
+ image: mysql:8
+ platform: linux/amd64
+ ports:
+ - "3306:3306"
+ environment:
+ - MYSQL_ROOT_PASSWORD=
+ - MYSQL_ALLOW_EMPTY_PASSWORD=yes
+ entrypoint:
+ sh -c "
+ echo 'CREATE DATABASE IF NOT EXISTS mooc;CREATE DATABASE IF NOT EXISTS backoffice;' > /docker-entrypoint-initdb.d/init.sql;
+ /usr/local/bin/docker-entrypoint.sh --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+ "
+ command: ["--default-authentication-plugin=mysql_native_password"]
+
+ shared_rabbitmq:
+ container_name: codely-java_ddd_example-rabbitmq
+ image: 'rabbitmq:3.7-management'
+ platform: linux/amd64
+ restart: unless-stopped
+ ports:
+ - "5630:5672"
+ - "8090:15672"
+ environment:
+ - RABBITMQ_DEFAULT_USER=codely
+ - RABBITMQ_DEFAULT_PASS=c0d3ly
+
+ backoffice_elasticsearch:
+ container_name: codely-java_ddd_example-elasticsearch
+ image: 'elasticsearch:6.8.4'
+ platform: linux/amd64
+ restart: unless-stopped
+ ports:
+ - "9300:9300"
+ - "9200:9200"
+ environment:
+ - discovery.type=single-node
+
+ test_server_java:
+ container_name: codely-java_ddd_example-test_server
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ volumes:
+ - .:/app:delegated
+ depends_on:
+ - shared_mysql
+ - shared_rabbitmq
+ - backoffice_elasticsearch
+ tty: true
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.yml b/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.yml
new file mode 100755
index 0000000..3da6fcb
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/docker-compose.yml
@@ -0,0 +1,113 @@
+version: '3'
+
+services:
+ shared_mysql:
+ container_name: codely-java_ddd_example-mysql
+ image: mysql:8
+ platform: linux/amd64
+ ports:
+ - "3306:3306"
+ environment:
+ - MYSQL_ROOT_PASSWORD=
+ - MYSQL_ALLOW_EMPTY_PASSWORD=yes
+ entrypoint:
+ sh -c "
+ echo 'CREATE DATABASE IF NOT EXISTS mooc;CREATE DATABASE IF NOT EXISTS backoffice;' > /docker-entrypoint-initdb.d/init.sql;
+ /usr/local/bin/docker-entrypoint.sh --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
+ "
+ command: ["--default-authentication-plugin=mysql_native_password"]
+
+ shared_rabbitmq:
+ container_name: codely-java_ddd_example-rabbitmq
+ image: 'rabbitmq:3.7-management'
+ platform: linux/amd64
+ restart: unless-stopped
+ ports:
+ - "5630:5672"
+ - "8090:15672"
+ environment:
+ - RABBITMQ_DEFAULT_USER=codely
+ - RABBITMQ_DEFAULT_PASS=c0d3ly
+
+ backoffice_elasticsearch:
+ container_name: codely-java_ddd_example-elasticsearch
+ image: 'elasticsearch:6.8.4'
+ platform: linux/amd64
+ restart: unless-stopped
+ ports:
+ - "9300:9300"
+ - "9200:9200"
+ environment:
+ - discovery.type=single-node
+
+ backoffice_backend_server_java:
+ container_name: codely-java_ddd_example-backoffice_backend_server
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "8040:8040"
+ volumes:
+ - .:/app:delegated
+ - backoffice_backend_gradle_cache:/app/.gradle
+ depends_on:
+ - shared_mysql
+ - shared_rabbitmq
+ - backoffice_elasticsearch
+ command: ["./gradlew", "bootRun", "--args", "backoffice_backend server"]
+
+ backoffice_frontend_server_java:
+ container_name: codely-java_ddd_example-backoffice_frontend_server
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "8041:8041"
+ volumes:
+ - .:/app:delegated
+ - backoffice_frontend_gradle_cache:/app/.gradle
+ depends_on:
+ - shared_mysql
+ - shared_rabbitmq
+ - backoffice_elasticsearch
+ command: ["./gradlew", "bootRun", "--args", "backoffice_frontend server"]
+
+ mooc_backend_server_java:
+ container_name: codely-java_ddd_example-mooc_backend_server
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "8030:8030"
+ volumes:
+ - .:/app:delegated
+ - mooc_backend_gradle_cache:/app/.gradle
+ depends_on:
+ - shared_mysql
+ - shared_rabbitmq
+ - backoffice_elasticsearch
+ command: ["./gradlew", "bootRun", "--args", "mooc_backend server"]
+
+ test_server_java:
+ container_name: codely-java_ddd_example-test_server
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ volumes:
+ - .:/app:delegated
+ - test_gradle_cache:/app/.gradle
+ depends_on:
+ - shared_mysql
+ - shared_rabbitmq
+ - backoffice_elasticsearch
+ tty: true
+
+volumes:
+ backoffice_backend_gradle_cache:
+ backoffice_frontend_gradle_cache:
+ mooc_backend_gradle_cache:
+ test_gradle_cache:
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.jar b/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..7f93135
Binary files /dev/null and b/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.properties b/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..3fa8f86
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/gradlew b/04-eventual_consistency/2-ensure_event_is_always_published/gradlew
new file mode 100755
index 0000000..1aa94a4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/gradlew.bat b/04-eventual_consistency/2-ensure_event_is_always_published/gradlew.bat
new file mode 100755
index 0000000..93e3f59
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/settings.gradle b/04-eventual_consistency/2-ensure_event_is_always_published/settings.gradle
new file mode 100644
index 0000000..4823818
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/settings.gradle
@@ -0,0 +1,13 @@
+rootProject.name = 'java-ddd-example'
+
+include ':shared'
+project(':shared').projectDir = new File('src/shared')
+
+include ':analytics'
+project(':analytics').projectDir = new File('src/analytics')
+
+include ':backoffice'
+project(':backoffice').projectDir = new File('src/backoffice')
+
+include ':mooc'
+project(':mooc').projectDir = new File('src/mooc')
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/DomainEventStorer.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/DomainEventStorer.java
new file mode 100644
index 0000000..674c72e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/DomainEventStorer.java
@@ -0,0 +1,22 @@
+package tv.codely.analytics.domain_events.application.store;
+
+import tv.codely.analytics.domain_events.domain.*;
+
+public final class DomainEventStorer {
+ private DomainEventsRepository repository;
+
+ public DomainEventStorer(DomainEventsRepository repository) {
+ this.repository = repository;
+ }
+
+ public void store(
+ AnalyticsDomainEventId id,
+ AnalyticsDomainEventAggregateId aggregateId,
+ AnalyticsDomainEventName name,
+ AnalyticsDomainEventBody body
+ ) {
+ AnalyticsDomainEvent domainEvent = new AnalyticsDomainEvent(id, aggregateId, name, body);
+
+ repository.save(domainEvent);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/StoreDomainEventOnOccurred.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/StoreDomainEventOnOccurred.java
new file mode 100644
index 0000000..23413ef
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/application/store/StoreDomainEventOnOccurred.java
@@ -0,0 +1,28 @@
+package tv.codely.analytics.domain_events.application.store;
+
+import org.springframework.context.event.EventListener;
+import tv.codely.analytics.domain_events.domain.AnalyticsDomainEventAggregateId;
+import tv.codely.analytics.domain_events.domain.AnalyticsDomainEventBody;
+import tv.codely.analytics.domain_events.domain.AnalyticsDomainEventId;
+import tv.codely.analytics.domain_events.domain.AnalyticsDomainEventName;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+import tv.codely.shared.domain.bus.event.DomainEventSubscriber;
+
+@DomainEventSubscriber({DomainEvent.class})
+public final class StoreDomainEventOnOccurred {
+ private final DomainEventStorer storer;
+
+ public StoreDomainEventOnOccurred(DomainEventStorer storer) {
+ this.storer = storer;
+ }
+
+ @EventListener
+ public void on(DomainEvent event) {
+ AnalyticsDomainEventId id = new AnalyticsDomainEventId(event.eventId());
+ AnalyticsDomainEventAggregateId aggregateId = new AnalyticsDomainEventAggregateId(event.aggregateId());
+ AnalyticsDomainEventName name = new AnalyticsDomainEventName(event.eventName());
+ AnalyticsDomainEventBody body = new AnalyticsDomainEventBody(event.toPrimitives());
+
+ storer.store(id, aggregateId, name, body);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEvent.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEvent.java
new file mode 100644
index 0000000..c2f310e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEvent.java
@@ -0,0 +1,37 @@
+package tv.codely.analytics.domain_events.domain;
+
+public final class AnalyticsDomainEvent {
+ private final AnalyticsDomainEventId id;
+ private final AnalyticsDomainEventAggregateId aggregateId;
+ private final AnalyticsDomainEventName name;
+ private final AnalyticsDomainEventBody body;
+
+ public AnalyticsDomainEvent(
+ AnalyticsDomainEventId id,
+ AnalyticsDomainEventAggregateId aggregateId,
+ AnalyticsDomainEventName name,
+ AnalyticsDomainEventBody body
+ ) {
+
+ this.id = id;
+ this.aggregateId = aggregateId;
+ this.name = name;
+ this.body = body;
+ }
+
+ public AnalyticsDomainEventId getId() {
+ return id;
+ }
+
+ public AnalyticsDomainEventAggregateId getAggregateId() {
+ return aggregateId;
+ }
+
+ public AnalyticsDomainEventName getName() {
+ return name;
+ }
+
+ public AnalyticsDomainEventBody getBody() {
+ return body;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventAggregateId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventAggregateId.java
new file mode 100644
index 0000000..beef90d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventAggregateId.java
@@ -0,0 +1,9 @@
+package tv.codely.analytics.domain_events.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class AnalyticsDomainEventAggregateId extends Identifier {
+ public AnalyticsDomainEventAggregateId(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventBody.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventBody.java
new file mode 100644
index 0000000..a706b7a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventBody.java
@@ -0,0 +1,16 @@
+package tv.codely.analytics.domain_events.domain;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+public final class AnalyticsDomainEventBody {
+ private HashMap value;
+
+ public HashMap getValue() {
+ return value;
+ }
+
+ public AnalyticsDomainEventBody(HashMap value) {
+ this.value = value;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventId.java
new file mode 100644
index 0000000..5f730dd
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventId.java
@@ -0,0 +1,9 @@
+package tv.codely.analytics.domain_events.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class AnalyticsDomainEventId extends Identifier {
+ public AnalyticsDomainEventId(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventName.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventName.java
new file mode 100644
index 0000000..d4a018e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/AnalyticsDomainEventName.java
@@ -0,0 +1,9 @@
+package tv.codely.analytics.domain_events.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class AnalyticsDomainEventName extends StringValueObject {
+ public AnalyticsDomainEventName(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/DomainEventsRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/DomainEventsRepository.java
new file mode 100644
index 0000000..c04ac90
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/analytics/main/tv/codely/analytics/domain_events/domain/DomainEventsRepository.java
@@ -0,0 +1,5 @@
+package tv.codely.analytics.domain_events.domain;
+
+public interface DomainEventsRepository {
+ void save(AnalyticsDomainEvent event);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/build.gradle b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/build.gradle
new file mode 100644
index 0000000..7d82dc7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/build.gradle
@@ -0,0 +1,2 @@
+dependencies {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice.sql b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice.sql
new file mode 100644
index 0000000..58f92fa
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS `courses` (
+ `id` CHAR(36) NOT NULL,
+ `name` VARCHAR(255) NOT NULL,
+ `duration` VARCHAR(255) NOT NULL,
+ PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice/backoffice_courses.json b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice/backoffice_courses.json
new file mode 100644
index 0000000..7891e8b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/resources/database/backoffice/backoffice_courses.json
@@ -0,0 +1,22 @@
+{
+ "mappings": {
+ "courses": {
+ "properties": {
+ "id": {
+ "type": "keyword",
+ "index": true
+ },
+ "name": {
+ "type": "text",
+ "index": true,
+ "fielddata": true
+ },
+ "duration": {
+ "type": "text",
+ "index": true,
+ "fielddata": true
+ }
+ }
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommand.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommand.java
new file mode 100644
index 0000000..5e1e74c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommand.java
@@ -0,0 +1,21 @@
+package tv.codely.backoffice.auth.application.authenticate;
+
+import tv.codely.shared.domain.bus.command.Command;
+
+public final class AuthenticateUserCommand implements Command {
+ private final String username;
+ private final String password;
+
+ public AuthenticateUserCommand(String username, String password) {
+ this.username = username;
+ this.password = password;
+ }
+
+ public String username() {
+ return username;
+ }
+
+ public String password() {
+ return password;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandler.java
new file mode 100644
index 0000000..ec43db6
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandler.java
@@ -0,0 +1,23 @@
+package tv.codely.backoffice.auth.application.authenticate;
+
+import tv.codely.backoffice.auth.domain.AuthPassword;
+import tv.codely.backoffice.auth.domain.AuthUsername;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.command.CommandHandler;
+
+@Service
+public final class AuthenticateUserCommandHandler implements CommandHandler {
+ private final UserAuthenticator authenticator;
+
+ public AuthenticateUserCommandHandler(UserAuthenticator authenticator) {
+ this.authenticator = authenticator;
+ }
+
+ @Override
+ public void handle(AuthenticateUserCommand command) {
+ AuthUsername username = new AuthUsername(command.username());
+ AuthPassword password = new AuthPassword(command.password());
+
+ authenticator.authenticate(username, password);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/UserAuthenticator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/UserAuthenticator.java
new file mode 100644
index 0000000..1b43c79
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/application/authenticate/UserAuthenticator.java
@@ -0,0 +1,34 @@
+package tv.codely.backoffice.auth.application.authenticate;
+
+import tv.codely.backoffice.auth.domain.*;
+import tv.codely.shared.domain.Service;
+
+import java.util.Optional;
+
+@Service
+public final class UserAuthenticator {
+ private final AuthRepository repository;
+
+ public UserAuthenticator(AuthRepository repository) {
+ this.repository = repository;
+ }
+
+ public void authenticate(AuthUsername username, AuthPassword password) {
+ Optional auth = repository.search(username);
+
+ ensureUserExist(auth, username);
+ ensureCredentialsAreValid(auth.get(), password);
+ }
+
+ private void ensureUserExist(Optional auth, AuthUsername username) {
+ if (!auth.isPresent()) {
+ throw new InvalidAuthUsername(username);
+ }
+ }
+
+ private void ensureCredentialsAreValid(AuthUser auth, AuthPassword password) {
+ if (!auth.passwordMatches(password)) {
+ throw new InvalidAuthCredentials(auth.username());
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthPassword.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthPassword.java
new file mode 100644
index 0000000..331588f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthPassword.java
@@ -0,0 +1,9 @@
+package tv.codely.backoffice.auth.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class AuthPassword extends StringValueObject {
+ public AuthPassword(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthRepository.java
new file mode 100644
index 0000000..37152ec
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthRepository.java
@@ -0,0 +1,7 @@
+package tv.codely.backoffice.auth.domain;
+
+import java.util.Optional;
+
+public interface AuthRepository {
+ Optional search(AuthUsername username);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUser.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUser.java
new file mode 100644
index 0000000..af0d45e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUser.java
@@ -0,0 +1,19 @@
+package tv.codely.backoffice.auth.domain;
+
+public final class AuthUser {
+ private final AuthUsername username;
+ private final AuthPassword password;
+
+ public AuthUser(AuthUsername username, AuthPassword password) {
+ this.username = username;
+ this.password = password;
+ }
+
+ public AuthUsername username() {
+ return username;
+ }
+
+ public boolean passwordMatches(AuthPassword password) {
+ return this.password.equals(password);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUsername.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUsername.java
new file mode 100644
index 0000000..6d1d48a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/AuthUsername.java
@@ -0,0 +1,9 @@
+package tv.codely.backoffice.auth.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class AuthUsername extends StringValueObject {
+ public AuthUsername(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthCredentials.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthCredentials.java
new file mode 100644
index 0000000..3092104
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthCredentials.java
@@ -0,0 +1,7 @@
+package tv.codely.backoffice.auth.domain;
+
+public final class InvalidAuthCredentials extends RuntimeException {
+ public InvalidAuthCredentials(AuthUsername username) {
+ super(String.format("The credentials for <%s> are invalid", username.value()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthUsername.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthUsername.java
new file mode 100644
index 0000000..857c10f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/domain/InvalidAuthUsername.java
@@ -0,0 +1,7 @@
+package tv.codely.backoffice.auth.domain;
+
+public final class InvalidAuthUsername extends RuntimeException {
+ public InvalidAuthUsername(AuthUsername username) {
+ super(String.format("The user <%s> does not exist", username.value()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/infrastructure/persistence/InMemoryAuthRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/infrastructure/persistence/InMemoryAuthRepository.java
new file mode 100644
index 0000000..e873c4f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/auth/infrastructure/persistence/InMemoryAuthRepository.java
@@ -0,0 +1,25 @@
+package tv.codely.backoffice.auth.infrastructure.persistence;
+
+import tv.codely.backoffice.auth.domain.AuthPassword;
+import tv.codely.backoffice.auth.domain.AuthRepository;
+import tv.codely.backoffice.auth.domain.AuthUser;
+import tv.codely.backoffice.auth.domain.AuthUsername;
+import tv.codely.shared.domain.Service;
+
+import java.util.HashMap;
+import java.util.Optional;
+
+@Service
+public final class InMemoryAuthRepository implements AuthRepository {
+ private final HashMap users = new HashMap() {{
+ put(new AuthUsername("javi"), new AuthPassword("barbitas"));
+ put(new AuthUsername("rafa"), new AuthPassword("pelazo"));
+ }};
+
+ @Override
+ public Optional search(AuthUsername username) {
+ return users.containsKey(username)
+ ? Optional.of(new AuthUser(username, users.get(username)))
+ : Optional.empty();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCourseResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCourseResponse.java
new file mode 100644
index 0000000..5871ac9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCourseResponse.java
@@ -0,0 +1,32 @@
+package tv.codely.backoffice.courses.application;
+
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.shared.domain.bus.query.Response;
+
+public final class BackofficeCourseResponse implements Response {
+ private final String id;
+ private final String name;
+ private final String duration;
+
+ public BackofficeCourseResponse(String id, String name, String duration) {
+ this.id = id;
+ this.name = name;
+ this.duration = duration;
+ }
+
+ public static BackofficeCourseResponse fromAggregate(BackofficeCourse course) {
+ return new BackofficeCourseResponse(course.id(), course.name(), course.duration());
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String duration() {
+ return duration;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCoursesResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCoursesResponse.java
new file mode 100644
index 0000000..1225864
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/BackofficeCoursesResponse.java
@@ -0,0 +1,17 @@
+package tv.codely.backoffice.courses.application;
+
+import tv.codely.shared.domain.bus.query.Response;
+
+import java.util.List;
+
+public final class BackofficeCoursesResponse implements Response {
+ private final List courses;
+
+ public BackofficeCoursesResponse(List courses) {
+ this.courses = courses;
+ }
+
+ public List courses() {
+ return courses;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/BackofficeCourseCreator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/BackofficeCourseCreator.java
new file mode 100644
index 0000000..f129d16
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/BackofficeCourseCreator.java
@@ -0,0 +1,18 @@
+package tv.codely.backoffice.courses.application.create;
+
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.Service;
+
+@Service
+public final class BackofficeCourseCreator {
+ private final BackofficeCourseRepository repository;
+
+ public BackofficeCourseCreator(BackofficeCourseRepository repository) {
+ this.repository = repository;
+ }
+
+ public void create(String id, String name, String duration) {
+ this.repository.save(new BackofficeCourse(id, name, duration));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/CreateBackofficeCourseOnCourseCreated.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/CreateBackofficeCourseOnCourseCreated.java
new file mode 100644
index 0000000..fd06214
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/create/CreateBackofficeCourseOnCourseCreated.java
@@ -0,0 +1,21 @@
+package tv.codely.backoffice.courses.application.create;
+
+import org.springframework.context.event.EventListener;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.DomainEventSubscriber;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+@Service
+@DomainEventSubscriber({CourseCreatedDomainEvent.class})
+public final class CreateBackofficeCourseOnCourseCreated {
+ private final BackofficeCourseCreator creator;
+
+ public CreateBackofficeCourseOnCourseCreated(BackofficeCourseCreator creator) {
+ this.creator = creator;
+ }
+
+ @EventListener
+ public void on(CourseCreatedDomainEvent event) {
+ creator.create(event.aggregateId(), event.name(), event.duration());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/AllBackofficeCoursesSearcher.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/AllBackofficeCoursesSearcher.java
new file mode 100644
index 0000000..db65ef0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/AllBackofficeCoursesSearcher.java
@@ -0,0 +1,23 @@
+package tv.codely.backoffice.courses.application.search_all;
+
+import tv.codely.backoffice.courses.application.BackofficeCourseResponse;
+import tv.codely.backoffice.courses.application.BackofficeCoursesResponse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.Service;
+
+import java.util.stream.Collectors;
+
+@Service
+public final class AllBackofficeCoursesSearcher {
+ private final BackofficeCourseRepository repository;
+
+ public AllBackofficeCoursesSearcher(BackofficeCourseRepository repository) {
+ this.repository = repository;
+ }
+
+ public BackofficeCoursesResponse search() {
+ return new BackofficeCoursesResponse(
+ repository.searchAll().stream().map(BackofficeCourseResponse::fromAggregate).collect(Collectors.toList())
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQuery.java
new file mode 100644
index 0000000..fc00a52
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQuery.java
@@ -0,0 +1,6 @@
+package tv.codely.backoffice.courses.application.search_all;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+public final class SearchAllBackofficeCoursesQuery implements Query {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQueryHandler.java
new file mode 100644
index 0000000..b500126
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_all/SearchAllBackofficeCoursesQueryHandler.java
@@ -0,0 +1,19 @@
+package tv.codely.backoffice.courses.application.search_all;
+
+import tv.codely.backoffice.courses.application.BackofficeCoursesResponse;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+
+@Service
+public final class SearchAllBackofficeCoursesQueryHandler implements QueryHandler {
+ private final AllBackofficeCoursesSearcher searcher;
+
+ public SearchAllBackofficeCoursesQueryHandler(AllBackofficeCoursesSearcher searcher) {
+ this.searcher = searcher;
+ }
+
+ @Override
+ public BackofficeCoursesResponse handle(SearchAllBackofficeCoursesQuery query) {
+ return searcher.search();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/BackofficeCoursesByCriteriaSearcher.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/BackofficeCoursesByCriteriaSearcher.java
new file mode 100644
index 0000000..1533079
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/BackofficeCoursesByCriteriaSearcher.java
@@ -0,0 +1,37 @@
+package tv.codely.backoffice.courses.application.search_by_criteria;
+
+import tv.codely.backoffice.courses.application.BackofficeCourseResponse;
+import tv.codely.backoffice.courses.application.BackofficeCoursesResponse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.domain.criteria.Filters;
+import tv.codely.shared.domain.criteria.Order;
+
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+public final class BackofficeCoursesByCriteriaSearcher {
+ private final BackofficeCourseRepository repository;
+
+ public BackofficeCoursesByCriteriaSearcher(BackofficeCourseRepository repository) {
+ this.repository = repository;
+ }
+
+ public BackofficeCoursesResponse search(
+ Filters filters,
+ Order order,
+ Optional limit,
+ Optional offset
+ ) {
+ Criteria criteria = new Criteria(filters, order, limit, offset);
+
+ return new BackofficeCoursesResponse(
+ repository.matching(criteria)
+ .stream()
+ .map(BackofficeCourseResponse::fromAggregate)
+ .collect(Collectors.toList())
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQuery.java
new file mode 100644
index 0000000..2671661
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQuery.java
@@ -0,0 +1,49 @@
+package tv.codely.backoffice.courses.application.search_by_criteria;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+
+public final class SearchBackofficeCoursesByCriteriaQuery implements Query {
+ private final List> filters;
+ private final Optional orderBy;
+ private final Optional orderType;
+ private final Optional limit;
+ private final Optional offset;
+
+ public SearchBackofficeCoursesByCriteriaQuery(
+ List> filters,
+ Optional orderBy,
+ Optional orderType,
+ Optional limit,
+ Optional offset
+ ) {
+ this.filters = filters;
+ this.orderBy = orderBy;
+ this.orderType = orderType;
+ this.limit = limit;
+ this.offset = offset;
+ }
+
+ public List> filters() {
+ return filters;
+ }
+
+ public Optional orderBy() {
+ return orderBy;
+ }
+
+ public Optional orderType() {
+ return orderType;
+ }
+
+ public Optional limit() {
+ return limit;
+ }
+
+ public Optional offset() {
+ return offset;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQueryHandler.java
new file mode 100644
index 0000000..8bf9363
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/application/search_by_criteria/SearchBackofficeCoursesByCriteriaQueryHandler.java
@@ -0,0 +1,24 @@
+package tv.codely.backoffice.courses.application.search_by_criteria;
+
+import tv.codely.backoffice.courses.application.BackofficeCoursesResponse;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+import tv.codely.shared.domain.criteria.Filters;
+import tv.codely.shared.domain.criteria.Order;
+
+@Service
+public final class SearchBackofficeCoursesByCriteriaQueryHandler implements QueryHandler {
+ private final BackofficeCoursesByCriteriaSearcher searcher;
+
+ public SearchBackofficeCoursesByCriteriaQueryHandler(BackofficeCoursesByCriteriaSearcher searcher) {
+ this.searcher = searcher;
+ }
+
+ @Override
+ public BackofficeCoursesResponse handle(SearchBackofficeCoursesByCriteriaQuery query) {
+ Filters filters = Filters.fromValues(query.filters());
+ Order order = Order.fromValues(query.orderBy(), query.orderType());
+
+ return searcher.search(filters, order, query.limit(), query.offset());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourse.java
new file mode 100644
index 0000000..f97d748
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourse.java
@@ -0,0 +1,71 @@
+package tv.codely.backoffice.courses.domain;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+public final class BackofficeCourse {
+ private final String id;
+ private final String name;
+ private final String duration;
+
+ public BackofficeCourse() {
+ id = null;
+ name = null;
+ duration = null;
+ }
+
+ public BackofficeCourse(String id, String name, String duration) {
+ this.id = id;
+ this.name = name;
+ this.duration = duration;
+ }
+
+ public static BackofficeCourse fromPrimitives(Map plainData) {
+ return new BackofficeCourse(
+ (String) plainData.get("id"),
+ (String) plainData.get("name"),
+ (String) plainData.get("duration")
+ );
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String duration() {
+ return duration;
+ }
+
+ public HashMap toPrimitives() {
+ return new HashMap() {{
+ put("id", id);
+ put("name", name);
+ put("duration", duration);
+ }};
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ BackofficeCourse that = (BackofficeCourse) o;
+ return id.equals(that.id) &&
+ name.equals(that.name) &&
+ duration.equals(that.duration);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, duration);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourseRepository.java
new file mode 100644
index 0000000..2183d54
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/domain/BackofficeCourseRepository.java
@@ -0,0 +1,13 @@
+package tv.codely.backoffice.courses.domain;
+
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.util.List;
+
+public interface BackofficeCourseRepository {
+ void save(BackofficeCourse course);
+
+ List searchAll();
+
+ List matching(Criteria criteria);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepository.java
new file mode 100644
index 0000000..cb84311
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepository.java
@@ -0,0 +1,39 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import org.springframework.context.annotation.Primary;
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.infrastructure.elasticsearch.ElasticsearchClient;
+import tv.codely.shared.infrastructure.elasticsearch.ElasticsearchRepository;
+
+import java.util.List;
+
+@Primary
+@Service
+public final class ElasticsearchBackofficeCourseRepository extends ElasticsearchRepository implements BackofficeCourseRepository {
+ public ElasticsearchBackofficeCourseRepository(ElasticsearchClient client) {
+ super(client);
+ }
+
+ @Override
+ public void save(BackofficeCourse course) {
+ persist(course.id(), course.toPrimitives());
+ }
+
+ @Override
+ public List searchAll() {
+ return searchAllInElastic(BackofficeCourse::fromPrimitives);
+ }
+
+ @Override
+ public List matching(Criteria criteria) {
+ return searchByCriteria(criteria, BackofficeCourse::fromPrimitives);
+ }
+
+ @Override
+ protected String moduleName() {
+ return "courses";
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepository.java
new file mode 100644
index 0000000..c616b49
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepository.java
@@ -0,0 +1,52 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public final class InMemoryCacheBackofficeCourseRepository implements BackofficeCourseRepository {
+ private final BackofficeCourseRepository repository;
+ private List courses = new ArrayList<>();
+ private HashMap> matchingCourses = new HashMap<>();
+
+ public InMemoryCacheBackofficeCourseRepository(BackofficeCourseRepository repository) {
+ this.repository = repository;
+ }
+
+ @Override
+ public void save(BackofficeCourse course) {
+ repository.save(course);
+ }
+
+ @Override
+ public List searchAll() {
+ return courses.isEmpty() ? searchAndFillCache() : courses;
+ }
+
+ @Override
+ public List matching(Criteria criteria) {
+ return matchingCourses.containsKey(criteria.serialize())
+ ? matchingCourses.get(criteria.serialize())
+ : searchMatchingAndFillCache(criteria);
+ }
+
+ private List searchMatchingAndFillCache(Criteria criteria) {
+ List courses = repository.matching(criteria);
+
+ this.matchingCourses.put(criteria.serialize(), courses);
+
+ return courses;
+ }
+
+ private List searchAndFillCache() {
+ List courses = repository.searchAll();
+
+ this.courses = courses;
+
+ return courses;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepository.java
new file mode 100644
index 0000000..bcb8976
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepository.java
@@ -0,0 +1,35 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.transaction.annotation.Transactional;
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.infrastructure.hibernate.HibernateRepository;
+
+import java.util.List;
+
+@Service
+@Transactional("backoffice-transaction_manager")
+public class MySqlBackofficeCourseRepository extends HibernateRepository implements BackofficeCourseRepository {
+ public MySqlBackofficeCourseRepository(@Qualifier("backoffice-session_factory") SessionFactory sessionFactory) {
+ super(sessionFactory, BackofficeCourse.class);
+ }
+
+ @Override
+ public void save(BackofficeCourse course) {
+ persist(course);
+ }
+
+ @Override
+ public List searchAll() {
+ return all();
+ }
+
+ @Override
+ public List matching(Criteria criteria) {
+ return byCriteria(criteria);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/hibernate/BackofficeCourse.hbm.xml b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/hibernate/BackofficeCourse.hbm.xml
new file mode 100644
index 0000000..a8592d0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/courses/infrastructure/persistence/hibernate/BackofficeCourse.hbm.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeElasticsearchConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeElasticsearchConfiguration.java
new file mode 100644
index 0000000..98c0f1a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeElasticsearchConfiguration.java
@@ -0,0 +1,89 @@
+package tv.codely.backoffice.shared.infrastructure.persistence;
+
+import org.apache.http.HttpHost;
+import org.elasticsearch.client.Request;
+import org.elasticsearch.client.RequestOptions;
+import org.elasticsearch.client.RestClient;
+import org.elasticsearch.client.RestHighLevelClient;
+import org.elasticsearch.client.indices.GetIndexRequest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import tv.codely.shared.domain.Utils;
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+import tv.codely.shared.infrastructure.elasticsearch.ElasticsearchClient;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.Scanner;
+
+@Configuration
+public class BackofficeElasticsearchConfiguration {
+ private final Parameter config;
+ private final ResourcePatternResolver resourceResolver;
+
+ public BackofficeElasticsearchConfiguration(Parameter config, ResourcePatternResolver resourceResolver) {
+ this.config = config;
+ this.resourceResolver = resourceResolver;
+ }
+
+ @Bean
+ public ElasticsearchClient elasticsearchClient() throws ParameterNotExist, Exception {
+ ElasticsearchClient client = new ElasticsearchClient(
+ new RestHighLevelClient(
+ RestClient.builder(
+ new HttpHost(
+ config.get("BACKOFFICE_ELASTICSEARCH_HOST"),
+ config.getInt("BACKOFFICE_ELASTICSEARCH_PORT"),
+ "http"
+ )
+ )
+ ),
+ RestClient.builder(
+ new HttpHost(
+ config.get("BACKOFFICE_ELASTICSEARCH_HOST"),
+ config.getInt("BACKOFFICE_ELASTICSEARCH_PORT"),
+ "http"
+ )).build(),
+ config.get("BACKOFFICE_ELASTICSEARCH_INDEX_PREFIX")
+ );
+
+ Utils.retry(10, 10000, () -> {
+ try {
+ generateIndexIfNotExists(client, "backoffice");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ return client;
+ }
+
+ private void generateIndexIfNotExists(ElasticsearchClient client, String contextName) throws IOException {
+ Resource[] jsonsIndexes = resourceResolver.getResources(
+ String.format("classpath:database/%s/*.json", contextName)
+ );
+
+ for (Resource jsonIndex : jsonsIndexes) {
+ String indexName = Objects.requireNonNull(jsonIndex.getFilename()).replace(".json", "");
+
+ if (!indexExists(indexName, client)) {
+ String indexBody = new Scanner(
+ jsonIndex.getInputStream(),
+ "UTF-8"
+ ).useDelimiter("\\A").next();
+
+ Request request = new Request("PUT", indexName);
+ request.setJsonEntity(indexBody);
+
+ client.lowLevelClient().performRequest(request);
+ }
+ }
+ }
+
+ private boolean indexExists(String indexName, ElasticsearchClient client) throws IOException {
+ return client.highLevelClient().indices().exists(new GetIndexRequest(indexName), RequestOptions.DEFAULT);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeHibernateConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeHibernateConfiguration.java
new file mode 100644
index 0000000..0615ff7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeHibernateConfiguration.java
@@ -0,0 +1,47 @@
+package tv.codely.backoffice.shared.infrastructure.persistence;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.orm.hibernate5.LocalSessionFactoryBean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+import tv.codely.shared.infrastructure.hibernate.HibernateConfigurationFactory;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+
+@Configuration
+@EnableTransactionManagement
+public class BackofficeHibernateConfiguration {
+ private final HibernateConfigurationFactory factory;
+ private final Parameter config;
+ private final String CONTEXT_NAME = "backoffice";
+
+ public BackofficeHibernateConfiguration(HibernateConfigurationFactory factory, Parameter config) {
+ this.factory = factory;
+ this.config = config;
+ }
+
+ @Bean("backoffice-transaction_manager")
+ public PlatformTransactionManager hibernateTransactionManager() throws IOException, ParameterNotExist {
+ return factory.hibernateTransactionManager(sessionFactory());
+ }
+
+ @Bean("backoffice-session_factory")
+ public LocalSessionFactoryBean sessionFactory() throws IOException, ParameterNotExist {
+ return factory.sessionFactory(CONTEXT_NAME, dataSource());
+ }
+
+ @Bean("backoffice-data_source")
+ public DataSource dataSource() throws IOException, ParameterNotExist {
+ return factory.dataSource(
+ config.get("BACKOFFICE_DATABASE_HOST"),
+ config.getInt("BACKOFFICE_DATABASE_PORT"),
+ config.get("BACKOFFICE_DATABASE_NAME"),
+ config.get("BACKOFFICE_DATABASE_USER"),
+ config.get("BACKOFFICE_DATABASE_PASSWORD")
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeMySqlEventBusConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeMySqlEventBusConfiguration.java
new file mode 100644
index 0000000..4704f7b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeMySqlEventBusConfiguration.java
@@ -0,0 +1,37 @@
+package tv.codely.backoffice.shared.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import tv.codely.shared.infrastructure.bus.event.DomainEventsInformation;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlDomainEventsConsumer;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlEventBus;
+import tv.codely.shared.infrastructure.bus.event.spring.SpringApplicationEventBus;
+
+@Configuration
+public class BackofficeMySqlEventBusConfiguration {
+ private final SessionFactory sessionFactory;
+ private final DomainEventsInformation domainEventsInformation;
+ private final SpringApplicationEventBus bus;
+
+ public BackofficeMySqlEventBusConfiguration(
+ @Qualifier("backoffice-session_factory") SessionFactory sessionFactory,
+ DomainEventsInformation domainEventsInformation,
+ SpringApplicationEventBus bus
+ ) {
+ this.sessionFactory = sessionFactory;
+ this.domainEventsInformation = domainEventsInformation;
+ this.bus = bus;
+ }
+
+ @Bean
+ public MySqlEventBus backofficeMysqlEventBus() {
+ return new MySqlEventBus(sessionFactory);
+ }
+
+ @Bean
+ public MySqlDomainEventsConsumer backofficeMySqlDomainEventsConsumer() {
+ return new MySqlDomainEventsConsumer(sessionFactory, domainEventsInformation, bus);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeRabbitMqEventBusConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeRabbitMqEventBusConfiguration.java
new file mode 100644
index 0000000..b0fae3c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/main/tv/codely/backoffice/shared/infrastructure/persistence/BackofficeRabbitMqEventBusConfiguration.java
@@ -0,0 +1,27 @@
+package tv.codely.backoffice.shared.infrastructure.persistence;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlEventBus;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqEventBus;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqPublisher;
+
+@Configuration
+public class BackofficeRabbitMqEventBusConfiguration {
+ private final RabbitMqPublisher publisher;
+ private final MySqlEventBus failoverPublisher;
+
+ public BackofficeRabbitMqEventBusConfiguration(
+ RabbitMqPublisher publisher,
+ @Qualifier("backofficeMysqlEventBus") MySqlEventBus failoverPublisher
+ ) {
+ this.publisher = publisher;
+ this.failoverPublisher = failoverPublisher;
+ }
+
+ @Bean
+ public RabbitMqEventBus backofficeRabbitMqEventBus() {
+ return new RabbitMqEventBus(publisher, failoverPublisher);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/BackofficeContextInfrastructureTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/BackofficeContextInfrastructureTestCase.java
new file mode 100644
index 0000000..efbad4e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/BackofficeContextInfrastructureTestCase.java
@@ -0,0 +1,21 @@
+package tv.codely.backoffice;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ContextConfiguration;
+import tv.codely.apps.backoffice.frontend.BackofficeFrontendApplication;
+import tv.codely.backoffice.courses.ElasticsearchEnvironmentArranger;
+import tv.codely.shared.infrastructure.InfrastructureTestCase;
+
+import java.io.IOException;
+
+@ContextConfiguration(classes = BackofficeFrontendApplication.class)
+@SpringBootTest
+public abstract class BackofficeContextInfrastructureTestCase extends InfrastructureTestCase {
+ @Autowired
+ private ElasticsearchEnvironmentArranger elasticsearchArranger;
+
+ protected void clearElasticsearch() throws IOException {
+ elasticsearchArranger.arrange("backoffice", "backoffice_courses");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/AuthModuleUnitTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/AuthModuleUnitTestCase.java
new file mode 100644
index 0000000..4ccc749
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/AuthModuleUnitTestCase.java
@@ -0,0 +1,31 @@
+package tv.codely.backoffice.auth;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.Mockito;
+import tv.codely.backoffice.auth.domain.AuthRepository;
+import tv.codely.backoffice.auth.domain.AuthUser;
+import tv.codely.backoffice.auth.domain.AuthUsername;
+import tv.codely.shared.infrastructure.UnitTestCase;
+
+import java.util.Optional;
+
+import static org.mockito.Mockito.mock;
+
+public abstract class AuthModuleUnitTestCase extends UnitTestCase {
+ protected AuthRepository repository;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ repository = mock(AuthRepository.class);
+ }
+
+ public void shouldSearch(AuthUsername username, AuthUser user) {
+ Mockito.when(repository.search(username)).thenReturn(Optional.of(user));
+ }
+
+ public void shouldSearch(AuthUsername username) {
+ Mockito.when(repository.search(username)).thenReturn(Optional.empty());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandlerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandlerShould.java
new file mode 100644
index 0000000..a37020e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandHandlerShould.java
@@ -0,0 +1,56 @@
+package tv.codely.backoffice.auth.application.authenticate;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tv.codely.backoffice.auth.AuthModuleUnitTestCase;
+import tv.codely.backoffice.auth.domain.AuthUser;
+import tv.codely.backoffice.auth.domain.AuthUserMother;
+import tv.codely.backoffice.auth.domain.InvalidAuthCredentials;
+import tv.codely.backoffice.auth.domain.InvalidAuthUsername;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+final class AuthenticateUserCommandHandlerShould extends AuthModuleUnitTestCase {
+ private AuthenticateUserCommandHandler handler;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ handler = new AuthenticateUserCommandHandler(new UserAuthenticator(repository));
+ }
+
+ @Test
+ void authenticate_a_valid_user() {
+ AuthenticateUserCommand command = AuthenticateUserCommandMother.random();
+ AuthUser authUser = AuthUserMother.fromCommand(command);
+
+ shouldSearch(authUser.username(), authUser);
+
+ handler.handle(command);
+ }
+
+ @Test
+ void throw_an_exception_when_the_user_does_not_exist() {
+ assertThrows(InvalidAuthUsername.class, () -> {
+ AuthenticateUserCommand command = AuthenticateUserCommandMother.random();
+ AuthUser authUser = AuthUserMother.fromCommand(command);
+
+ shouldSearch(authUser.username());
+
+ handler.handle(command);
+ });
+ }
+
+ @Test
+ void throw_an_exception_when_the_password_does_not_math() {
+ assertThrows(InvalidAuthCredentials.class, () -> {
+ AuthenticateUserCommand command = AuthenticateUserCommandMother.random();
+ AuthUser authUser = AuthUserMother.withUsername(command.username());
+
+ shouldSearch(authUser.username(), authUser);
+
+ handler.handle(command);
+ });
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandMother.java
new file mode 100644
index 0000000..42746f5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/application/authenticate/AuthenticateUserCommandMother.java
@@ -0,0 +1,16 @@
+package tv.codely.backoffice.auth.application.authenticate;
+
+import tv.codely.backoffice.auth.domain.AuthPassword;
+import tv.codely.backoffice.auth.domain.AuthPasswordMother;
+import tv.codely.backoffice.auth.domain.AuthUsername;
+import tv.codely.backoffice.auth.domain.AuthUsernameMother;
+
+public final class AuthenticateUserCommandMother {
+ public static AuthenticateUserCommand create(AuthUsername username, AuthPassword password) {
+ return new AuthenticateUserCommand(username.value(), password.value());
+ }
+
+ public static AuthenticateUserCommand random() {
+ return create(AuthUsernameMother.random(), AuthPasswordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthPasswordMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthPasswordMother.java
new file mode 100644
index 0000000..8e4f0ab
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthPasswordMother.java
@@ -0,0 +1,13 @@
+package tv.codely.backoffice.auth.domain;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class AuthPasswordMother {
+ public static AuthPassword create(String value) {
+ return new AuthPassword(value);
+ }
+
+ public static AuthPassword random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUserMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUserMother.java
new file mode 100644
index 0000000..14e26c9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUserMother.java
@@ -0,0 +1,21 @@
+package tv.codely.backoffice.auth.domain;
+
+import tv.codely.backoffice.auth.application.authenticate.AuthenticateUserCommand;
+
+public final class AuthUserMother {
+ public static AuthUser create(AuthUsername username, AuthPassword password) {
+ return new AuthUser(username, password);
+ }
+
+ public static AuthUser random() {
+ return create(AuthUsernameMother.random(), AuthPasswordMother.random());
+ }
+
+ public static AuthUser fromCommand(AuthenticateUserCommand command) {
+ return create(AuthUsernameMother.create(command.username()), AuthPasswordMother.create(command.password()));
+ }
+
+ public static AuthUser withUsername(String username) {
+ return create(AuthUsernameMother.create(username), AuthPasswordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUsernameMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUsernameMother.java
new file mode 100644
index 0000000..e96670c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/auth/domain/AuthUsernameMother.java
@@ -0,0 +1,13 @@
+package tv.codely.backoffice.auth.domain;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class AuthUsernameMother {
+ public static AuthUsername create(String value) {
+ return new AuthUsername(value);
+ }
+
+ public static AuthUsername random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/ElasticsearchEnvironmentArranger.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/ElasticsearchEnvironmentArranger.java
new file mode 100644
index 0000000..08df2f5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/ElasticsearchEnvironmentArranger.java
@@ -0,0 +1,47 @@
+package tv.codely.backoffice.courses;
+
+import org.elasticsearch.client.Request;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.infrastructure.elasticsearch.ElasticsearchClient;
+
+import java.io.IOException;
+import java.util.Objects;
+import java.util.Scanner;
+
+@Service
+public final class ElasticsearchEnvironmentArranger {
+ ResourcePatternResolver resourceResolver;
+ ElasticsearchClient client;
+
+ public ElasticsearchEnvironmentArranger(
+ ResourcePatternResolver resourceResolver,
+ ElasticsearchClient client
+ ) {
+ this.resourceResolver = resourceResolver;
+ this.client = client;
+ }
+
+ public void arrange(String contextName, String index) throws IOException {
+ client.delete(index);
+
+ Resource[] jsonsIndexes = resourceResolver.getResources(
+ String.format("classpath:database/%s/%s.json", contextName, index)
+ );
+
+ for (Resource jsonIndex : jsonsIndexes) {
+ String indexName = Objects.requireNonNull(jsonIndex.getFilename()).replace(".json", "");
+
+ String indexBody = new Scanner(
+ jsonIndex.getInputStream(),
+ "UTF-8"
+ ).useDelimiter("\\A").next();
+
+ Request request = new Request("PUT", indexName);
+ request.setJsonEntity(indexBody);
+
+ client.lowLevelClient().performRequest(request);
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseCriteriaMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseCriteriaMother.java
new file mode 100644
index 0000000..ee83f69
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseCriteriaMother.java
@@ -0,0 +1,17 @@
+package tv.codely.backoffice.courses.domain;
+
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.domain.criteria.Filter;
+import tv.codely.shared.domain.criteria.Filters;
+import tv.codely.shared.domain.criteria.Order;
+
+import java.util.Arrays;
+
+public final class BackofficeCourseCriteriaMother {
+ public static Criteria nameAndDurationContains(String name, String duration) {
+ Filter nameFilter = Filter.create("name", "contains", name);
+ Filter durationFilter = Filter.create("duration", "contains", duration);
+
+ return new Criteria(new Filters(Arrays.asList(nameFilter, durationFilter)), Order.asc("name"));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseMother.java
new file mode 100644
index 0000000..76a1b4b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/domain/BackofficeCourseMother.java
@@ -0,0 +1,18 @@
+package tv.codely.backoffice.courses.domain;
+
+import tv.codely.shared.domain.UuidMother;
+import tv.codely.shared.domain.WordMother;
+
+public final class BackofficeCourseMother {
+ public static BackofficeCourse create(String id, String name, String duration) {
+ return new BackofficeCourse(id, name, duration);
+ }
+
+ public static BackofficeCourse create(String name, String duration) {
+ return new BackofficeCourse(UuidMother.random(), name, duration);
+ }
+
+ public static BackofficeCourse random() {
+ return create(UuidMother.random(), WordMother.random(), WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepositoryShould.java
new file mode 100644
index 0000000..5f63289
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/ElasticsearchBackofficeCourseRepositoryShould.java
@@ -0,0 +1,62 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.backoffice.BackofficeContextInfrastructureTestCase;
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseCriteriaMother;
+import tv.codely.backoffice.courses.domain.BackofficeCourseMother;
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+final class ElasticsearchBackofficeCourseRepositoryShould extends BackofficeContextInfrastructureTestCase {
+ @Autowired
+ private ElasticsearchBackofficeCourseRepository repository;
+
+ @BeforeEach
+ protected void setUp() throws IOException {
+ clearElasticsearch();
+ }
+
+ @Test
+ void save_a_course() {
+ repository.save(BackofficeCourseMother.random());
+ }
+
+ @Test
+ void search_all_existing_courses() throws Exception {
+ BackofficeCourse course = BackofficeCourseMother.random();
+ BackofficeCourse anotherCourse = BackofficeCourseMother.random();
+
+ List expected = Arrays.asList(course, anotherCourse);
+
+ repository.save(course);
+ repository.save(anotherCourse);
+
+ eventually(() -> assertEquals(expected, repository.searchAll()));
+ }
+
+ @Test
+ void search_courses_using_a_criteria() throws Exception {
+ BackofficeCourse matchingCourse = BackofficeCourseMother.create("DDD en Java", "3 days");
+ BackofficeCourse anotherMatchingCourse = BackofficeCourseMother.create("DDD en TypeScript", "2.5 days");
+ BackofficeCourse intellijCourse = BackofficeCourseMother.create("Exprimiendo Intellij", "48 hours");
+ BackofficeCourse cobolCourse = BackofficeCourseMother.create("DDD en Cobol", "5 years");
+
+ Criteria criteria = BackofficeCourseCriteriaMother.nameAndDurationContains("DDD", "days");
+ List expected = Arrays.asList(matchingCourse, anotherMatchingCourse);
+
+ repository.save(matchingCourse);
+ repository.save(anotherMatchingCourse);
+ repository.save(intellijCourse);
+ repository.save(cobolCourse);
+
+ eventually(() -> assertEquals(expected, repository.matching(criteria)));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepositoryShould.java
new file mode 100644
index 0000000..e82dffb
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/InMemoryCacheBackofficeCourseRepositoryShould.java
@@ -0,0 +1,100 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import tv.codely.backoffice.BackofficeContextInfrastructureTestCase;
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseCriteriaMother;
+import tv.codely.backoffice.courses.domain.BackofficeCourseMother;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.mockito.Mockito.*;
+
+final class InMemoryCacheBackofficeCourseRepositoryShould extends BackofficeContextInfrastructureTestCase {
+ private InMemoryCacheBackofficeCourseRepository repository;
+ private BackofficeCourseRepository innerRepository;
+
+ @BeforeEach
+ protected void setUp() {
+ innerRepository = mock(BackofficeCourseRepository.class);
+ repository = new InMemoryCacheBackofficeCourseRepository(innerRepository);
+ }
+
+ @Test
+ void save_a_course() {
+ BackofficeCourse course = BackofficeCourseMother.random();
+
+ repository.save(course);
+
+ shouldHaveSaved(course);
+ }
+
+ @Test
+ void search_all_existing_courses() {
+ BackofficeCourse course = BackofficeCourseMother.random();
+ BackofficeCourse anotherCourse = BackofficeCourseMother.random();
+ List courses = Arrays.asList(course, anotherCourse);
+
+ shouldSearchAll(courses);
+
+ assertThat(courses, containsInAnyOrder(repository.searchAll().toArray()));
+ }
+
+ @Test
+ void search_all_existing_courses_without_hitting_the_inner_repository_the_second_time() {
+ BackofficeCourse course = BackofficeCourseMother.random();
+ BackofficeCourse anotherCourse = BackofficeCourseMother.random();
+ List courses = Arrays.asList(course, anotherCourse);
+
+ shouldSearchAll(courses);
+
+ assertThat(courses, containsInAnyOrder(repository.searchAll().toArray()));
+ assertThat(courses, containsInAnyOrder(repository.searchAll().toArray()));
+ }
+
+ @Test
+ void search_courses_using_a_criteria() {
+ BackofficeCourse matchingCourse = BackofficeCourseMother.create("DDD en Java", "3 days");
+ BackofficeCourse anotherMatchingCourse = BackofficeCourseMother.create("DDD en TypeScript", "2.5 days");
+ List matchingCourses = Arrays.asList(matchingCourse, anotherMatchingCourse);
+
+ Criteria criteria = BackofficeCourseCriteriaMother.nameAndDurationContains("DDD", "days");
+
+ shouldSearchMatching(criteria, matchingCourses);
+
+ assertThat(matchingCourses, containsInAnyOrder(repository.matching(criteria).toArray()));
+ }
+
+ @Test
+ void search_courses_using_a_criteria_without_hitting_the_inner_repository_the_second_time() {
+ BackofficeCourse matchingCourse = BackofficeCourseMother.create("DDD en Java", "3 days");
+ BackofficeCourse anotherMatchingCourse = BackofficeCourseMother.create("DDD en TypeScript", "2.5 days");
+ List matchingCourses = Arrays.asList(matchingCourse, anotherMatchingCourse);
+
+ Criteria criteria = BackofficeCourseCriteriaMother.nameAndDurationContains("DDD", "days");
+
+ shouldSearchMatching(criteria, matchingCourses);
+
+ assertThat(matchingCourses, containsInAnyOrder(repository.matching(criteria).toArray()));
+ assertThat(matchingCourses, containsInAnyOrder(repository.matching(criteria).toArray()));
+ }
+
+ private void shouldSearchAll(List courses) {
+ Mockito.when(innerRepository.searchAll()).thenReturn(courses);
+ }
+
+ private void shouldSearchMatching(Criteria criteria, List courses) {
+ Mockito.when(innerRepository.matching(criteria)).thenReturn(courses);
+ }
+
+ private void shouldHaveSaved(BackofficeCourse course) {
+ verify(innerRepository, atLeastOnce()).save(course);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepositoryShould.java
new file mode 100644
index 0000000..8d9d5ce
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/backoffice/test/tv/codely/backoffice/courses/infrastructure/persistence/MySqlBackofficeCourseRepositoryShould.java
@@ -0,0 +1,60 @@
+package tv.codely.backoffice.courses.infrastructure.persistence;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import tv.codely.backoffice.BackofficeContextInfrastructureTestCase;
+import tv.codely.backoffice.courses.domain.BackofficeCourse;
+import tv.codely.backoffice.courses.domain.BackofficeCourseCriteriaMother;
+import tv.codely.backoffice.courses.domain.BackofficeCourseMother;
+import tv.codely.backoffice.courses.domain.BackofficeCourseRepository;
+import tv.codely.shared.domain.criteria.Criteria;
+
+import jakarta.transaction.Transactional;
+import java.util.Arrays;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+
+@Transactional
+class MySqlBackofficeCourseRepositoryShould extends BackofficeContextInfrastructureTestCase {
+ @Autowired
+ @Qualifier("mySqlBackofficeCourseRepository")
+ private BackofficeCourseRepository repository;
+
+ @Test
+ void save_a_course() {
+ repository.save(BackofficeCourseMother.random());
+ }
+
+ @Test
+ void search_all_existing_courses() {
+ BackofficeCourse course = BackofficeCourseMother.random();
+ BackofficeCourse anotherCourse = BackofficeCourseMother.random();
+
+ repository.save(course);
+ repository.save(anotherCourse);
+
+ assertThat(Arrays.asList(course, anotherCourse), containsInAnyOrder(repository.searchAll().toArray()));
+ }
+
+ @Test
+ void search_courses_using_a_criteria() {
+ BackofficeCourse matchingCourse = BackofficeCourseMother.create("DDD en Java", "3 days");
+ BackofficeCourse anotherMatchingCourse = BackofficeCourseMother.create("DDD en TypeScript", "2.5 days");
+ BackofficeCourse intellijCourse = BackofficeCourseMother.create("Exprimiendo Intellij", "48 hours");
+ BackofficeCourse cobolCourse = BackofficeCourseMother.create("DDD en Cobol", "5 years");
+
+ Criteria criteria = BackofficeCourseCriteriaMother.nameAndDurationContains("DDD", "days");
+
+ repository.save(matchingCourse);
+ repository.save(anotherMatchingCourse);
+ repository.save(intellijCourse);
+ repository.save(cobolCourse);
+
+ assertThat(
+ Arrays.asList(matchingCourse, anotherMatchingCourse),
+ containsInAnyOrder(repository.matching(criteria).toArray())
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/build.gradle b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/build.gradle
new file mode 100644
index 0000000..7d82dc7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/build.gradle
@@ -0,0 +1,2 @@
+dependencies {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/resources/database/mooc.sql b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/resources/database/mooc.sql
new file mode 100644
index 0000000..e3ac50b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/resources/database/mooc.sql
@@ -0,0 +1,63 @@
+CREATE TABLE IF NOT EXISTS courses (
+ id CHAR(36) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ duration VARCHAR(255) NOT NULL,
+ PRIMARY KEY (id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS courses_counter (
+ id CHAR(36) NOT NULL,
+ total INT NOT NULL,
+ existing_courses JSON NOT NULL,
+ PRIMARY KEY (id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+INSERT IGNORE INTO courses_counter (id, total, existing_courses)
+VALUES ('efbaff16-8fcd-4689-9fc9-ec545d641c46', 0, '[]');
+
+CREATE TABLE IF NOT EXISTS steps (
+ id CHAR(36) NOT NULL,
+ title VARCHAR(155) NOT NULL,
+ PRIMARY KEY (id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS steps_challenges (
+ id CHAR(36) NOT NULL,
+ statement TEXT NOT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_steps_challenges__step_id FOREIGN KEY (id) REFERENCES steps(id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS steps_videos (
+ id CHAR(36) NOT NULL,
+ url VARCHAR(255) NOT NULL,
+ text TEXT NOT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_steps_video__step_id FOREIGN KEY (id) REFERENCES steps(id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS domain_events (
+ id CHAR(36) NOT NULL,
+ aggregate_id CHAR(36) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ body JSON NOT NULL,
+ occurred_on TIMESTAMP NOT NULL,
+ PRIMARY KEY (id)
+)
+ ENGINE = InnoDB
+ DEFAULT CHARSET = utf8mb4
+ COLLATE = utf8mb4_unicode_ci;
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CourseResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CourseResponse.java
new file mode 100644
index 0000000..22c8798
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CourseResponse.java
@@ -0,0 +1,32 @@
+package tv.codely.mooc.courses.application;
+
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.shared.domain.bus.query.Response;
+
+public final class CourseResponse implements Response {
+ private final String id;
+ private final String name;
+ private final String duration;
+
+ public CourseResponse(String id, String name, String duration) {
+ this.id = id;
+ this.name = name;
+ this.duration = duration;
+ }
+
+ public static CourseResponse fromAggregate(Course course) {
+ return new CourseResponse(course.id().value(), course.name().value(), course.duration().value());
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String duration() {
+ return duration;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CoursesResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CoursesResponse.java
new file mode 100644
index 0000000..cd39f43
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/CoursesResponse.java
@@ -0,0 +1,17 @@
+package tv.codely.mooc.courses.application;
+
+import tv.codely.shared.domain.bus.query.Response;
+
+import java.util.List;
+
+public final class CoursesResponse implements Response {
+ private final List courses;
+
+ public CoursesResponse(List courses) {
+ this.courses = courses;
+ }
+
+ public List courses() {
+ return courses;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CourseCreator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CourseCreator.java
new file mode 100644
index 0000000..dbbec32
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CourseCreator.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.courses.application.create;
+
+import tv.codely.mooc.courses.domain.*;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.EventBus;
+
+@Service
+public final class CourseCreator {
+ private final CourseRepository repository;
+ private final EventBus eventBus;
+
+ public CourseCreator(CourseRepository repository, EventBus eventBus) {
+ this.repository = repository;
+ this.eventBus = eventBus;
+ }
+
+ public void create(CourseId id, CourseName name, CourseDuration duration) {
+ Course course = Course.create(id, name, duration);
+
+ repository.save(course);
+ eventBus.publish(course.pullDomainEvents());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommand.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommand.java
new file mode 100644
index 0000000..5f6f783
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommand.java
@@ -0,0 +1,27 @@
+package tv.codely.mooc.courses.application.create;
+
+import tv.codely.shared.domain.bus.command.Command;
+
+public final class CreateCourseCommand implements Command {
+ private final String id;
+ private final String name;
+ private final String duration;
+
+ public CreateCourseCommand(String id, String name, String duration) {
+ this.id = id;
+ this.name = name;
+ this.duration = duration;
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String duration() {
+ return duration;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommandHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommandHandler.java
new file mode 100644
index 0000000..85127f4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/create/CreateCourseCommandHandler.java
@@ -0,0 +1,25 @@
+package tv.codely.mooc.courses.application.create;
+
+import tv.codely.mooc.courses.domain.CourseDuration;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseName;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.command.CommandHandler;
+
+@Service
+public final class CreateCourseCommandHandler implements CommandHandler {
+ private final CourseCreator creator;
+
+ public CreateCourseCommandHandler(CourseCreator creator) {
+ this.creator = creator;
+ }
+
+ @Override
+ public void handle(CreateCourseCommand command) {
+ CourseId id = new CourseId(command.id());
+ CourseName name = new CourseName(command.name());
+ CourseDuration duration = new CourseDuration(command.duration());
+
+ creator.create(id, name, duration);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/CourseFinder.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/CourseFinder.java
new file mode 100644
index 0000000..b912a3e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/CourseFinder.java
@@ -0,0 +1,22 @@
+package tv.codely.mooc.courses.application.find;
+
+import tv.codely.mooc.courses.application.CourseResponse;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseNotExist;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.shared.domain.Service;
+
+@Service
+public final class CourseFinder {
+ private final CourseRepository repository;
+
+ public CourseFinder(CourseRepository repository) {
+ this.repository = repository;
+ }
+
+ public CourseResponse find(CourseId id) throws CourseNotExist {
+ return repository.search(id)
+ .map(CourseResponse::fromAggregate)
+ .orElseThrow(() -> new CourseNotExist(id));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQuery.java
new file mode 100644
index 0000000..187e5e0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQuery.java
@@ -0,0 +1,15 @@
+package tv.codely.mooc.courses.application.find;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+public final class FindCourseQuery implements Query {
+ private final String id;
+
+ public FindCourseQuery(String id) {
+ this.id = id;
+ }
+
+ public String id() {
+ return id;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQueryHandler.java
new file mode 100644
index 0000000..bc8d380
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/find/FindCourseQueryHandler.java
@@ -0,0 +1,21 @@
+package tv.codely.mooc.courses.application.find;
+
+import tv.codely.mooc.courses.application.CourseResponse;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseNotExist;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+
+@Service
+public final class FindCourseQueryHandler implements QueryHandler {
+ private final CourseFinder finder;
+
+ public FindCourseQueryHandler(CourseFinder finder) {
+ this.finder = finder;
+ }
+
+ @Override
+ public CourseResponse handle(FindCourseQuery query) throws CourseNotExist {
+ return finder.find(new CourseId(query.id()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/LastCoursesSearcher.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/LastCoursesSearcher.java
new file mode 100644
index 0000000..1e0f6fa
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/LastCoursesSearcher.java
@@ -0,0 +1,34 @@
+package tv.codely.mooc.courses.application.search_last;
+
+import tv.codely.mooc.courses.application.CourseResponse;
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.domain.criteria.Filters;
+import tv.codely.shared.domain.criteria.Order;
+
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+@Service
+public final class LastCoursesSearcher {
+ private final CourseRepository repository;
+
+ public LastCoursesSearcher(CourseRepository repository) {
+ this.repository = repository;
+ }
+
+ public CoursesResponse search(int courses) {
+ Criteria criteria = new Criteria(
+ Filters.none(),
+ Order.none(),
+ Optional.of(courses),
+ Optional.empty()
+ );
+
+ return new CoursesResponse(
+ repository.matching(criteria).stream().map(CourseResponse::fromAggregate).collect(Collectors.toList())
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQuery.java
new file mode 100644
index 0000000..834a847
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQuery.java
@@ -0,0 +1,34 @@
+package tv.codely.mooc.courses.application.search_last;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+import java.util.Objects;
+
+public final class SearchLastCoursesQuery implements Query {
+ private final Integer total;
+
+ public SearchLastCoursesQuery(Integer total) {
+ this.total = total;
+ }
+
+ public Integer total() {
+ return total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ SearchLastCoursesQuery that = (SearchLastCoursesQuery) o;
+ return total.equals(that.total);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(total);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryHandler.java
new file mode 100644
index 0000000..91bea0f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryHandler.java
@@ -0,0 +1,19 @@
+package tv.codely.mooc.courses.application.search_last;
+
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+
+@Service
+public final class SearchLastCoursesQueryHandler implements QueryHandler {
+ private final LastCoursesSearcher searcher;
+
+ public SearchLastCoursesQueryHandler(LastCoursesSearcher searcher) {
+ this.searcher = searcher;
+ }
+
+ @Override
+ public CoursesResponse handle(SearchLastCoursesQuery query) {
+ return searcher.search(query.total());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/Course.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/Course.java
new file mode 100644
index 0000000..ef44a89
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/Course.java
@@ -0,0 +1,63 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.AggregateRoot;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+import java.util.Objects;
+
+public final class Course extends AggregateRoot {
+ private final CourseId id;
+ private final CourseName name;
+ private final CourseDuration duration;
+
+ public Course(CourseId id, CourseName name, CourseDuration duration) {
+ this.id = id;
+ this.name = name;
+ this.duration = duration;
+ }
+
+ private Course() {
+ id = null;
+ name = null;
+ duration = null;
+ }
+
+ public static Course create(CourseId id, CourseName name, CourseDuration duration) {
+ Course course = new Course(id, name, duration);
+
+ course.record(new CourseCreatedDomainEvent(id.value(), name.value(), duration.value()));
+
+ return course;
+ }
+
+ public CourseId id() {
+ return id;
+ }
+
+ public CourseName name() {
+ return name;
+ }
+
+ public CourseDuration duration() {
+ return duration;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Course course = (Course) o;
+ return id.equals(course.id) &&
+ name.equals(course.name) &&
+ duration.equals(course.duration);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, name, duration);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseDuration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseDuration.java
new file mode 100644
index 0000000..42c4482
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseDuration.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class CourseDuration extends StringValueObject {
+ public CourseDuration(String value) {
+ super(value);
+ }
+
+ private CourseDuration() {
+ super("");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseId.java
new file mode 100644
index 0000000..dd6c3c2
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseId.java
@@ -0,0 +1,12 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class CourseId extends Identifier {
+ public CourseId(String value) {
+ super(value);
+ }
+
+ public CourseId() {
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseName.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseName.java
new file mode 100644
index 0000000..67e0a06
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseName.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class CourseName extends StringValueObject {
+ public CourseName(String value) {
+ super(value);
+ }
+
+ public CourseName() {
+ super("");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseNotExist.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseNotExist.java
new file mode 100644
index 0000000..d3490be
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseNotExist.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.DomainError;
+
+public final class CourseNotExist extends DomainError {
+ public CourseNotExist(CourseId id) {
+ super("course_not_exist", String.format("The course <%s> doesn't exist", id.value()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseRepository.java
new file mode 100644
index 0000000..4d2e696
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/domain/CourseRepository.java
@@ -0,0 +1,14 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface CourseRepository {
+ void save(Course course);
+
+ Optional search(CourseId id);
+
+ List matching(Criteria criteria);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepository.java
new file mode 100644
index 0000000..622d60a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepository.java
@@ -0,0 +1,28 @@
+package tv.codely.mooc.courses.infrastructure.persistence;
+
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.shared.domain.criteria.Criteria;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Optional;
+
+public final class InMemoryCourseRepository implements CourseRepository {
+ private HashMap courses = new HashMap<>();
+
+ @Override
+ public void save(Course course) {
+ courses.put(course.id().value(), course);
+ }
+
+ public Optional search(CourseId id) {
+ return Optional.ofNullable(courses.get(id.value()));
+ }
+
+ @Override
+ public List matching(Criteria criteria) {
+ return null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepository.java
new file mode 100644
index 0000000..b90c0cf
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepository.java
@@ -0,0 +1,37 @@
+package tv.codely.mooc.courses.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.transaction.annotation.Transactional;
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.criteria.Criteria;
+import tv.codely.shared.infrastructure.hibernate.HibernateRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Service
+@Transactional("mooc-transaction_manager")
+public class MySqlCourseRepository extends HibernateRepository implements CourseRepository {
+ public MySqlCourseRepository(@Qualifier("mooc-session_factory") SessionFactory sessionFactory) {
+ super(sessionFactory, Course.class);
+ }
+
+ @Override
+ public void save(Course course) {
+ persist(course);
+ }
+
+ @Override
+ public Optional search(CourseId id) {
+ return byId(id);
+ }
+
+ @Override
+ public List matching(Criteria criteria) {
+ return byCriteria(criteria);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/hibernate/Course.hbm.xml b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/hibernate/Course.hbm.xml
new file mode 100644
index 0000000..88809da
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses/infrastructure/persistence/hibernate/Course.hbm.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterFinder.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterFinder.java
new file mode 100644
index 0000000..e0ccfe7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterFinder.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterNotInitialized;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterRepository;
+import tv.codely.shared.domain.Service;
+
+@Service
+public final class CoursesCounterFinder {
+ private CoursesCounterRepository repository;
+
+ public CoursesCounterFinder(CoursesCounterRepository repository) {
+ this.repository = repository;
+ }
+
+ public CoursesCounterResponse find() {
+ CoursesCounter coursesCounter = repository.search().orElseGet(() -> {
+ throw new CoursesCounterNotInitialized();
+ });
+
+ return new CoursesCounterResponse(coursesCounter.total().value());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponse.java
new file mode 100644
index 0000000..a4ee91a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponse.java
@@ -0,0 +1,34 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import tv.codely.shared.domain.bus.query.Response;
+
+import java.util.Objects;
+
+public final class CoursesCounterResponse implements Response {
+ private Integer total;
+
+ public CoursesCounterResponse(Integer total) {
+ this.total = total;
+ }
+
+ public Integer total() {
+ return total;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CoursesCounterResponse that = (CoursesCounterResponse) o;
+ return total.equals(that.total);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(total);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQuery.java
new file mode 100644
index 0000000..7586b4b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQuery.java
@@ -0,0 +1,6 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+public final class FindCoursesCounterQuery implements Query {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandler.java
new file mode 100644
index 0000000..1eefb82
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandler.java
@@ -0,0 +1,18 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+
+@Service
+public final class FindCoursesCounterQueryHandler implements QueryHandler {
+ private final CoursesCounterFinder finder;
+
+ public FindCoursesCounterQueryHandler(CoursesCounterFinder finder) {
+ this.finder = finder;
+ }
+
+ @Override
+ public CoursesCounterResponse handle(FindCoursesCounterQuery query) {
+ return finder.find();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/CoursesCounterIncrementer.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/CoursesCounterIncrementer.java
new file mode 100644
index 0000000..c4433c0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/CoursesCounterIncrementer.java
@@ -0,0 +1,29 @@
+package tv.codely.mooc.courses_counter.application.increment;
+
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.UuidGenerator;
+
+@Service
+public final class CoursesCounterIncrementer {
+ private CoursesCounterRepository repository;
+ private UuidGenerator uuidGenerator;
+
+ public CoursesCounterIncrementer(CoursesCounterRepository repository, UuidGenerator uuidGenerator) {
+ this.repository = repository;
+ this.uuidGenerator = uuidGenerator;
+ }
+
+ public void increment(CourseId id) {
+ CoursesCounter counter = repository.search()
+ .orElseGet(() -> CoursesCounter.initialize(uuidGenerator.generate()));
+
+ if (!counter.hasIncremented(id)) {
+ counter.increment(id);
+
+ repository.save(counter);
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreated.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreated.java
new file mode 100644
index 0000000..f8465fb
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreated.java
@@ -0,0 +1,24 @@
+package tv.codely.mooc.courses_counter.application.increment;
+
+import org.springframework.context.event.EventListener;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.DomainEventSubscriber;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+@Service
+@DomainEventSubscriber({CourseCreatedDomainEvent.class})
+public final class IncrementCoursesCounterOnCourseCreated {
+ private final CoursesCounterIncrementer incrementer;
+
+ public IncrementCoursesCounterOnCourseCreated(CoursesCounterIncrementer incrementer) {
+ this.incrementer = incrementer;
+ }
+
+ @EventListener
+ public void on(CourseCreatedDomainEvent event) {
+ CourseId courseId = new CourseId(event.aggregateId());
+
+ incrementer.increment(courseId);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounter.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounter.java
new file mode 100644
index 0000000..2a13583
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounter.java
@@ -0,0 +1,69 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.mooc.courses.domain.CourseId;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+public final class CoursesCounter {
+ private CoursesCounterId id;
+ private CoursesCounterTotal total;
+ private List existingCourses;
+
+ public CoursesCounter(CoursesCounterId id, CoursesCounterTotal total, List existingCourses) {
+ this.id = id;
+ this.total = total;
+ this.existingCourses = existingCourses;
+ }
+
+ private CoursesCounter() {
+ this.id = null;
+ this.total = null;
+ this.existingCourses = null;
+ }
+
+ public static CoursesCounter initialize(String id) {
+ return new CoursesCounter(new CoursesCounterId(id), CoursesCounterTotal.initialize(), new ArrayList<>());
+ }
+
+ public CoursesCounterId id() {
+ return id;
+ }
+
+ public CoursesCounterTotal total() {
+ return total;
+ }
+
+ public List existingCourses() {
+ return existingCourses;
+ }
+
+ public boolean hasIncremented(CourseId id) {
+ return existingCourses.contains(id);
+ }
+
+ public void increment(CourseId id) {
+ total = total.increment();
+ existingCourses.add(id);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CoursesCounter that = (CoursesCounter) o;
+ return id.equals(that.id) &&
+ total.equals(that.total) &&
+ existingCourses.equals(that.existingCourses);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, total, existingCourses);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterId.java
new file mode 100644
index 0000000..3899c15
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterId.java
@@ -0,0 +1,12 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class CoursesCounterId extends Identifier {
+ public CoursesCounterId(String value) {
+ super(value);
+ }
+
+ private CoursesCounterId() {
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterNotInitialized.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterNotInitialized.java
new file mode 100644
index 0000000..b20ca87
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterNotInitialized.java
@@ -0,0 +1,4 @@
+package tv.codely.mooc.courses_counter.domain;
+
+public final class CoursesCounterNotInitialized extends RuntimeException {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterRepository.java
new file mode 100644
index 0000000..ccbbb71
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterRepository.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import java.util.Optional;
+
+public interface CoursesCounterRepository {
+ void save(CoursesCounter counter);
+
+ Optional search();
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterTotal.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterTotal.java
new file mode 100644
index 0000000..65a299d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/domain/CoursesCounterTotal.java
@@ -0,0 +1,21 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.shared.domain.IntValueObject;
+
+public final class CoursesCounterTotal extends IntValueObject {
+ public CoursesCounterTotal(Integer value) {
+ super(value);
+ }
+
+ public CoursesCounterTotal() {
+ super(null);
+ }
+
+ public static CoursesCounterTotal initialize() {
+ return new CoursesCounterTotal(0);
+ }
+
+ public CoursesCounterTotal increment() {
+ return new CoursesCounterTotal(value() + 1);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepository.java
new file mode 100644
index 0000000..f9cb245
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepository.java
@@ -0,0 +1,32 @@
+package tv.codely.mooc.courses_counter.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.transaction.annotation.Transactional;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.infrastructure.hibernate.HibernateRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Service
+@Transactional("mooc-transaction_manager")
+public class MySqlCoursesCounterRepository extends HibernateRepository implements CoursesCounterRepository {
+ public MySqlCoursesCounterRepository(@Qualifier("mooc-session_factory") SessionFactory sessionFactory) {
+ super(sessionFactory, CoursesCounter.class);
+ }
+
+ @Override
+ public void save(CoursesCounter counter) {
+ persist(counter);
+ }
+
+ @Override
+ public Optional search() {
+ List coursesCounter = all();
+
+ return 0 == coursesCounter.size() ? Optional.empty() : Optional.ofNullable(coursesCounter.get(0));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/hibernate/CoursesCounter.hbm.xml b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/hibernate/CoursesCounter.hbm.xml
new file mode 100644
index 0000000..8ea7482
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/courses_counter/infrastructure/persistence/hibernate/CoursesCounter.hbm.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tv.codely.mooc.courses.domain.CourseId
+
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/NewCoursesNewsletterSender.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/NewCoursesNewsletterSender.java
new file mode 100644
index 0000000..0e2ea20
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/NewCoursesNewsletterSender.java
@@ -0,0 +1,52 @@
+package tv.codely.mooc.notifications.application.send_new_courses_newsletter;
+
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.mooc.courses.application.search_last.SearchLastCoursesQuery;
+import tv.codely.mooc.notifications.domain.EmailSender;
+import tv.codely.mooc.notifications.domain.NewCoursesNewsletter;
+import tv.codely.mooc.students.application.StudentResponse;
+import tv.codely.mooc.students.application.StudentsResponse;
+import tv.codely.mooc.students.application.search_all.SearchAllStudentsQuery;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.UuidGenerator;
+import tv.codely.shared.domain.bus.event.EventBus;
+import tv.codely.shared.domain.bus.query.QueryBus;
+
+@Service
+public final class NewCoursesNewsletterSender {
+ private final static Integer TOTAL_COURSES = 3;
+ private final QueryBus queryBus;
+ private final EmailSender sender;
+ private final UuidGenerator uuidGenerator;
+ private final EventBus eventBus;
+
+ public NewCoursesNewsletterSender(
+ QueryBus queryBus,
+ UuidGenerator uuidGenerator,
+ EmailSender sender,
+ EventBus eventBus
+ ) {
+ this.queryBus = queryBus;
+ this.uuidGenerator = uuidGenerator;
+ this.sender = sender;
+ this.eventBus = eventBus;
+ }
+
+ public void send() {
+ CoursesResponse courses = queryBus.ask(new SearchLastCoursesQuery(TOTAL_COURSES));
+
+ if (courses.courses().size() > 0) {
+ StudentsResponse students = queryBus.ask(new SearchAllStudentsQuery());
+
+ students.students().forEach(student -> send(student, courses));
+ }
+ }
+
+ public void send(StudentResponse student, CoursesResponse courses) {
+ NewCoursesNewsletter newsletter = NewCoursesNewsletter.send(uuidGenerator.generate(), student, courses);
+
+ sender.send(newsletter);
+
+ eventBus.publish(newsletter.pullDomainEvents());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommand.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommand.java
new file mode 100644
index 0000000..51f6c52
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommand.java
@@ -0,0 +1,15 @@
+package tv.codely.mooc.notifications.application.send_new_courses_newsletter;
+
+import tv.codely.shared.domain.bus.command.Command;
+
+public final class SendNewCoursesNewsletterCommand implements Command {
+ private final String id;
+
+ public SendNewCoursesNewsletterCommand(String id) {
+ this.id = id;
+ }
+
+ public String id() {
+ return id;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandler.java
new file mode 100644
index 0000000..2c6dad7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandler.java
@@ -0,0 +1,18 @@
+package tv.codely.mooc.notifications.application.send_new_courses_newsletter;
+
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.command.CommandHandler;
+
+@Service
+public final class SendNewCoursesNewsletterCommandHandler implements CommandHandler {
+ private final NewCoursesNewsletterSender sender;
+
+ public SendNewCoursesNewsletterCommandHandler(NewCoursesNewsletterSender sender) {
+ this.sender = sender;
+ }
+
+ @Override
+ public void handle(SendNewCoursesNewsletterCommand command) {
+ sender.send();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/Email.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/Email.java
new file mode 100644
index 0000000..120feff
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/Email.java
@@ -0,0 +1,62 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.shared.domain.AggregateRoot;
+
+import java.util.Objects;
+
+public abstract class Email extends AggregateRoot {
+ private final EmailId id;
+ private final String from;
+ private final String to;
+ private final String subject;
+ private final String body;
+
+ public Email(EmailId id, String from, String to, String subject, String body) {
+ this.id = id;
+ this.from = from;
+ this.to = to;
+ this.subject = subject;
+ this.body = body;
+ }
+
+ public EmailId id() {
+ return id;
+ }
+
+ public String from() {
+ return from;
+ }
+
+ public String to() {
+ return to;
+ }
+
+ public String subject() {
+ return subject;
+ }
+
+ public String body() {
+ return body;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Email email = (Email) o;
+ return id.equals(email.id) &&
+ from.equals(email.from) &&
+ to.equals(email.to) &&
+ subject.equals(email.subject) &&
+ body.equals(email.body);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, from, to, subject, body);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailId.java
new file mode 100644
index 0000000..dd03323
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailId.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class EmailId extends Identifier {
+ public EmailId(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailSender.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailSender.java
new file mode 100644
index 0000000..b393b2d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/EmailSender.java
@@ -0,0 +1,5 @@
+package tv.codely.mooc.notifications.domain;
+
+public interface EmailSender {
+ void send(Email email);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletter.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletter.java
new file mode 100644
index 0000000..0fc60cd
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletter.java
@@ -0,0 +1,55 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.mooc.students.application.StudentResponse;
+
+import java.util.Objects;
+
+public final class NewCoursesNewsletter extends Email {
+ private final StudentResponse student;
+ private final CoursesResponse courses;
+
+ public NewCoursesNewsletter(EmailId id, StudentResponse student, CoursesResponse courses) {
+ super(id, "news@codely.tv", student.email(), "Último cursos en CodelyTV", formatBody(student, courses));
+
+ this.student = student;
+ this.courses = courses;
+ }
+
+ private static String formatBody(StudentResponse student, CoursesResponse courses) {
+ return String.format(
+ "Hoy es tu día de suerte... %s vas a ver %s nuevos cursos",
+ student.name(),
+ courses.courses().size()
+ );
+ }
+
+ public static NewCoursesNewsletter send(String id, StudentResponse student, CoursesResponse courses) {
+ NewCoursesNewsletter newsletter = new NewCoursesNewsletter(new EmailId(id), student, courses);
+
+ newsletter.record(new NewCoursesNewsletterEmailSent(id, student.id()));
+
+ return newsletter;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+ NewCoursesNewsletter that = (NewCoursesNewsletter) o;
+ return student.equals(that.student) &&
+ courses.equals(that.courses);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), student, courses);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSent.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSent.java
new file mode 100644
index 0000000..b03f209
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSent.java
@@ -0,0 +1,78 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Objects;
+
+public final class NewCoursesNewsletterEmailSent extends DomainEvent {
+ private final String studentId;
+
+ public NewCoursesNewsletterEmailSent() {
+ super(null);
+
+ this.studentId = null;
+ }
+
+ public NewCoursesNewsletterEmailSent(String aggregateId, String studentId) {
+ super(aggregateId);
+
+ this.studentId = studentId;
+ }
+
+ public NewCoursesNewsletterEmailSent(
+ String aggregateId,
+ String eventId,
+ String occurredOn,
+ String studentId
+ ) {
+ super(aggregateId, eventId, occurredOn);
+
+ this.studentId = studentId;
+ }
+
+ @Override
+ public String eventName() {
+ return "new_courses_newsletter_email.sent";
+ }
+
+ @Override
+ public HashMap toPrimitives() {
+ return new HashMap() {{
+ put("student_id", studentId);
+ }};
+ }
+
+ @Override
+ public NewCoursesNewsletterEmailSent fromPrimitives(
+ String aggregateId,
+ HashMap body,
+ String eventId,
+ String occurredOn
+ ) {
+ return new NewCoursesNewsletterEmailSent(
+ aggregateId,
+ eventId,
+ occurredOn,
+ (String) body.get("student_id")
+ );
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ NewCoursesNewsletterEmailSent that = (NewCoursesNewsletterEmailSent) o;
+ return studentId.equals(that.studentId);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(studentId);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/infrastructure/FakeEmailSender.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/infrastructure/FakeEmailSender.java
new file mode 100644
index 0000000..17501f3
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/notifications/infrastructure/FakeEmailSender.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.notifications.infrastructure;
+
+import tv.codely.mooc.notifications.domain.Email;
+import tv.codely.mooc.notifications.domain.EmailSender;
+import tv.codely.shared.domain.Service;
+
+@Service
+public final class FakeEmailSender implements EmailSender {
+ @Override
+ public void send(Email email) {
+ // In the future...
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocHibernateConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocHibernateConfiguration.java
new file mode 100644
index 0000000..88f915d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocHibernateConfiguration.java
@@ -0,0 +1,47 @@
+package tv.codely.mooc.shared.infrastructure.persistence;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.orm.hibernate5.LocalSessionFactoryBean;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+import tv.codely.shared.infrastructure.config.Parameter;
+import tv.codely.shared.infrastructure.config.ParameterNotExist;
+import tv.codely.shared.infrastructure.hibernate.HibernateConfigurationFactory;
+
+import javax.sql.DataSource;
+import java.io.IOException;
+
+@Configuration
+@EnableTransactionManagement
+public class MoocHibernateConfiguration {
+ private final HibernateConfigurationFactory factory;
+ private final Parameter config;
+ private final String CONTEXT_NAME = "mooc";
+
+ public MoocHibernateConfiguration(HibernateConfigurationFactory factory, Parameter config) {
+ this.factory = factory;
+ this.config = config;
+ }
+
+ @Bean("mooc-transaction_manager")
+ public PlatformTransactionManager hibernateTransactionManager() throws IOException, ParameterNotExist {
+ return factory.hibernateTransactionManager(sessionFactory());
+ }
+
+ @Bean("mooc-session_factory")
+ public LocalSessionFactoryBean sessionFactory() throws IOException, ParameterNotExist {
+ return factory.sessionFactory(CONTEXT_NAME, dataSource());
+ }
+
+ @Bean("mooc-data_source")
+ public DataSource dataSource() throws IOException, ParameterNotExist {
+ return factory.dataSource(
+ config.get("MOOC_DATABASE_HOST"),
+ config.getInt("MOOC_DATABASE_PORT"),
+ config.get("MOOC_DATABASE_NAME"),
+ config.get("MOOC_DATABASE_USER"),
+ config.get("MOOC_DATABASE_PASSWORD")
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocMySqlEventBusConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocMySqlEventBusConfiguration.java
new file mode 100644
index 0000000..dc8f346
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocMySqlEventBusConfiguration.java
@@ -0,0 +1,37 @@
+package tv.codely.mooc.shared.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import tv.codely.shared.infrastructure.bus.event.DomainEventsInformation;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlDomainEventsConsumer;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlEventBus;
+import tv.codely.shared.infrastructure.bus.event.spring.SpringApplicationEventBus;
+
+@Configuration
+public class MoocMySqlEventBusConfiguration {
+ private final SessionFactory sessionFactory;
+ private final DomainEventsInformation domainEventsInformation;
+ private final SpringApplicationEventBus bus;
+
+ public MoocMySqlEventBusConfiguration(
+ @Qualifier("mooc-session_factory") SessionFactory sessionFactory,
+ DomainEventsInformation domainEventsInformation,
+ SpringApplicationEventBus bus
+ ) {
+ this.sessionFactory = sessionFactory;
+ this.domainEventsInformation = domainEventsInformation;
+ this.bus = bus;
+ }
+
+ @Bean
+ public MySqlEventBus moocMysqlEventBus() {
+ return new MySqlEventBus(sessionFactory);
+ }
+
+ @Bean
+ public MySqlDomainEventsConsumer moocMySqlDomainEventsConsumer() {
+ return new MySqlDomainEventsConsumer(sessionFactory, domainEventsInformation, bus);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocRabbitMqEventBusConfiguration.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocRabbitMqEventBusConfiguration.java
new file mode 100644
index 0000000..df81787
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/shared/infrastructure/persistence/MoocRabbitMqEventBusConfiguration.java
@@ -0,0 +1,27 @@
+package tv.codely.mooc.shared.infrastructure.persistence;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlEventBus;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqEventBus;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqPublisher;
+
+@Configuration
+public class MoocRabbitMqEventBusConfiguration {
+ private final RabbitMqPublisher publisher;
+ private final MySqlEventBus failoverPublisher;
+
+ public MoocRabbitMqEventBusConfiguration(
+ RabbitMqPublisher publisher,
+ @Qualifier("moocMysqlEventBus") MySqlEventBus failoverPublisher
+ ) {
+ this.publisher = publisher;
+ this.failoverPublisher = failoverPublisher;
+ }
+
+ @Bean
+ public RabbitMqEventBus moocRabbitMqEventBus() {
+ return new RabbitMqEventBus(publisher, failoverPublisher);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/Step.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/Step.java
new file mode 100644
index 0000000..838d110
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/Step.java
@@ -0,0 +1,35 @@
+package tv.codely.mooc.steps.domain;
+
+import java.util.Objects;
+
+public abstract class Step {
+ private final StepId id;
+ private final StepTitle title;
+
+ public Step(StepId id, StepTitle title) {
+ this.id = id;
+ this.title = title;
+ }
+
+ public StepId id() {
+ return id;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Step step = (Step) o;
+ return id.equals(step.id) &&
+ title.equals(step.title);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, title);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepId.java
new file mode 100644
index 0000000..7f5d07b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepId.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.steps.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class StepId extends Identifier {
+ public StepId(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepRepository.java
new file mode 100644
index 0000000..419f53d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepRepository.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.steps.domain;
+
+import java.util.Optional;
+
+public interface StepRepository {
+ void save(Step step);
+
+ Optional extends Step> search(StepId id);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepTitle.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepTitle.java
new file mode 100644
index 0000000..b1a14a5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/StepTitle.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class StepTitle extends StringValueObject {
+ public StepTitle(String value) {
+ super(value);
+ }
+
+ private StepTitle() {
+ super(null);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStep.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStep.java
new file mode 100644
index 0000000..9d2be39
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStep.java
@@ -0,0 +1,43 @@
+package tv.codely.mooc.steps.domain.challenge;
+
+import tv.codely.mooc.steps.domain.Step;
+import tv.codely.mooc.steps.domain.StepId;
+import tv.codely.mooc.steps.domain.StepTitle;
+
+import java.util.Objects;
+
+public final class ChallengeStep extends Step {
+ private final ChallengeStepStatement statement;
+
+ public ChallengeStep(StepId id, StepTitle title, ChallengeStepStatement statement) {
+ super(id, title);
+
+ this.statement = statement;
+ }
+
+ private ChallengeStep() {
+ super(null, null);
+
+ this.statement = null;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ if (!super.equals(o)) {
+ return false;
+ }
+ ChallengeStep that = (ChallengeStep) o;
+ return statement.equals(that.statement);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(super.hashCode(), statement);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatement.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatement.java
new file mode 100644
index 0000000..bfc8a40
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatement.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain.challenge;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class ChallengeStepStatement extends StringValueObject {
+ public ChallengeStepStatement(String value) {
+ super(value);
+ }
+
+ public ChallengeStepStatement() {
+ super(null);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStep.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStep.java
new file mode 100644
index 0000000..81f6c2d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStep.java
@@ -0,0 +1,25 @@
+package tv.codely.mooc.steps.domain.video;
+
+import tv.codely.mooc.steps.domain.Step;
+import tv.codely.mooc.steps.domain.StepId;
+import tv.codely.mooc.steps.domain.StepTitle;
+import tv.codely.shared.domain.VideoUrl;
+
+public final class VideoStep extends Step {
+ private final VideoUrl videoUrl;
+ private final VideoStepText text;
+
+ public VideoStep(StepId id, StepTitle title, VideoUrl videoUrl, VideoStepText text) {
+ super(id, title);
+
+ this.videoUrl = videoUrl;
+ this.text = text;
+ }
+
+ private VideoStep() {
+ super(null, null);
+
+ this.videoUrl = null;
+ this.text = null;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStepText.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStepText.java
new file mode 100644
index 0000000..99e36c9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/domain/video/VideoStepText.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain.video;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class VideoStepText extends StringValueObject {
+ public VideoStepText(String value) {
+ super(value);
+ }
+
+ private VideoStepText() {
+ super(null);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepository.java
new file mode 100644
index 0000000..6b4b103
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepository.java
@@ -0,0 +1,30 @@
+package tv.codely.mooc.steps.infrastructure.persistence;
+
+import org.hibernate.SessionFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.transaction.annotation.Transactional;
+import tv.codely.mooc.steps.domain.Step;
+import tv.codely.mooc.steps.domain.StepId;
+import tv.codely.mooc.steps.domain.StepRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.infrastructure.hibernate.HibernateRepository;
+
+import java.util.Optional;
+
+@Service
+@Transactional("mooc-transaction_manager")
+public class MySqlStepRepository extends HibernateRepository implements StepRepository {
+ public MySqlStepRepository(@Qualifier("mooc-session_factory") SessionFactory sessionFactory) {
+ super(sessionFactory, Step.class);
+ }
+
+ @Override
+ public void save(Step step) {
+ persist(step);
+ }
+
+ @Override
+ public Optional extends Step> search(StepId id) {
+ return byId(id);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/hibernate/VideoStep.hbm.xml b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/hibernate/VideoStep.hbm.xml
new file mode 100644
index 0000000..31cd919
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/steps/infrastructure/persistence/hibernate/VideoStep.hbm.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentResponse.java
new file mode 100644
index 0000000..9b8bc05
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentResponse.java
@@ -0,0 +1,38 @@
+package tv.codely.mooc.students.application;
+
+import tv.codely.mooc.students.domain.Student;
+import tv.codely.shared.domain.bus.query.Response;
+
+public final class StudentResponse implements Response {
+ private final String id;
+ private final String name;
+ private final String surname;
+ private final String email;
+
+ public StudentResponse(String id, String name, String surname, String email) {
+ this.id = id;
+ this.name = name;
+ this.surname = surname;
+ this.email = email;
+ }
+
+ public static StudentResponse fromAggregate(Student student) {
+ return new StudentResponse(student.id().value(), student.name(), student.surname(), student.email());
+ }
+
+ public String id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String surname() {
+ return surname;
+ }
+
+ public String email() {
+ return email;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentsResponse.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentsResponse.java
new file mode 100644
index 0000000..9a7e035
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/StudentsResponse.java
@@ -0,0 +1,17 @@
+package tv.codely.mooc.students.application;
+
+import tv.codely.shared.domain.bus.query.Response;
+
+import java.util.List;
+
+public final class StudentsResponse implements Response {
+ private final List students;
+
+ public StudentsResponse(List students) {
+ this.students = students;
+ }
+
+ public List students() {
+ return students;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/AllStudentsSearcher.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/AllStudentsSearcher.java
new file mode 100644
index 0000000..a079520
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/AllStudentsSearcher.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.students.application.search_all;
+
+import tv.codely.mooc.students.application.StudentResponse;
+import tv.codely.mooc.students.application.StudentsResponse;
+import tv.codely.mooc.students.domain.StudentRepository;
+import tv.codely.shared.domain.Service;
+
+import java.util.stream.Collectors;
+
+@Service
+public final class AllStudentsSearcher {
+ private final StudentRepository repository;
+
+ public AllStudentsSearcher(StudentRepository repository) {
+ this.repository = repository;
+ }
+
+ public StudentsResponse search() {
+ return new StudentsResponse(
+ repository.searchAll().stream().map(StudentResponse::fromAggregate).collect(Collectors.toList())
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQuery.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQuery.java
new file mode 100644
index 0000000..618a9fa
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQuery.java
@@ -0,0 +1,20 @@
+package tv.codely.mooc.students.application.search_all;
+
+import tv.codely.shared.domain.bus.query.Query;
+
+import java.util.Objects;
+
+public final class SearchAllStudentsQuery implements Query {
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ return o != null && getClass() == o.getClass();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash("SearchAllStudentsQuery");
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryHandler.java
new file mode 100644
index 0000000..c9e3fa4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryHandler.java
@@ -0,0 +1,19 @@
+package tv.codely.mooc.students.application.search_all;
+
+import tv.codely.mooc.students.application.StudentsResponse;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.query.QueryHandler;
+
+@Service
+public final class SearchAllStudentsQueryHandler implements QueryHandler {
+ private final AllStudentsSearcher searcher;
+
+ public SearchAllStudentsQueryHandler(AllStudentsSearcher searcher) {
+ this.searcher = searcher;
+ }
+
+ @Override
+ public StudentsResponse handle(SearchAllStudentsQuery query) {
+ return searcher.search();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/Student.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/Student.java
new file mode 100644
index 0000000..eb0353d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/Student.java
@@ -0,0 +1,31 @@
+package tv.codely.mooc.students.domain;
+
+public final class Student {
+ private final StudentId id;
+ private final String name;
+ private final String surname;
+ private final String email;
+
+ public Student(StudentId id, String name, String surname, String email) {
+ this.id = id;
+ this.name = name;
+ this.surname = surname;
+ this.email = email;
+ }
+
+ public StudentId id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String surname() {
+ return surname;
+ }
+
+ public String email() {
+ return email;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentId.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentId.java
new file mode 100644
index 0000000..3e6735e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentId.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.students.domain;
+
+import tv.codely.shared.domain.Identifier;
+
+public final class StudentId extends Identifier {
+ public StudentId(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentRepository.java
new file mode 100644
index 0000000..0fbd2e6
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/domain/StudentRepository.java
@@ -0,0 +1,7 @@
+package tv.codely.mooc.students.domain;
+
+import java.util.List;
+
+public interface StudentRepository {
+ List searchAll();
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/infrastructure/InMemoryStudentRepository.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/infrastructure/InMemoryStudentRepository.java
new file mode 100644
index 0000000..aabd3ab
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/main/tv/codely/mooc/students/infrastructure/InMemoryStudentRepository.java
@@ -0,0 +1,27 @@
+package tv.codely.mooc.students.infrastructure;
+
+import tv.codely.mooc.students.domain.Student;
+import tv.codely.mooc.students.domain.StudentId;
+import tv.codely.mooc.students.domain.StudentRepository;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.UuidGenerator;
+
+import java.util.Arrays;
+import java.util.List;
+
+@Service
+public final class InMemoryStudentRepository implements StudentRepository {
+ private UuidGenerator generator;
+
+ public InMemoryStudentRepository(UuidGenerator generator) {
+ this.generator = generator;
+ }
+
+ @Override
+ public List searchAll() {
+ return Arrays.asList(
+ new Student(new StudentId(generator.generate()), "name", "surname", "email@mail.com"),
+ new Student(new StudentId(generator.generate()), "Other name", "Other surname", "another@mail.com")
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/MoocContextInfrastructureTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/MoocContextInfrastructureTestCase.java
new file mode 100644
index 0000000..eb574c4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/MoocContextInfrastructureTestCase.java
@@ -0,0 +1,11 @@
+package tv.codely.mooc;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ContextConfiguration;
+import tv.codely.apps.mooc.backend.MoocBackendApplication;
+import tv.codely.shared.infrastructure.InfrastructureTestCase;
+
+@ContextConfiguration(classes = MoocBackendApplication.class)
+@SpringBootTest
+public abstract class MoocContextInfrastructureTestCase extends InfrastructureTestCase {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleInfrastructureTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleInfrastructureTestCase.java
new file mode 100644
index 0000000..a52cf56
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleInfrastructureTestCase.java
@@ -0,0 +1,12 @@
+package tv.codely.mooc.courses;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.mooc.MoocContextInfrastructureTestCase;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.mooc.courses.infrastructure.persistence.InMemoryCourseRepository;
+
+public abstract class CoursesModuleInfrastructureTestCase extends MoocContextInfrastructureTestCase {
+ protected InMemoryCourseRepository inMemoryCourseRepository = new InMemoryCourseRepository();
+ @Autowired
+ protected CourseRepository mySqlCourseRepository;
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleUnitTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleUnitTestCase.java
new file mode 100644
index 0000000..eb7454d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/CoursesModuleUnitTestCase.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.courses;
+
+import org.junit.jupiter.api.BeforeEach;
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseRepository;
+import tv.codely.shared.infrastructure.UnitTestCase;
+
+import static org.mockito.Mockito.*;
+
+public abstract class CoursesModuleUnitTestCase extends UnitTestCase {
+ protected CourseRepository repository;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ repository = mock(CourseRepository.class);
+ }
+
+ public void shouldHaveSaved(Course course) {
+ verify(repository, atLeastOnce()).save(course);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CourseResponseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CourseResponseMother.java
new file mode 100644
index 0000000..564e527
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CourseResponseMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.application;
+
+import tv.codely.mooc.courses.domain.*;
+
+public final class CourseResponseMother {
+ public static CourseResponse create(CourseId id, CourseName name, CourseDuration duration) {
+ return new CourseResponse(id.value(), name.value(), duration.value());
+ }
+
+ public static CourseResponse random() {
+ return create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CoursesResponseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CoursesResponseMother.java
new file mode 100644
index 0000000..1067e22
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/CoursesResponseMother.java
@@ -0,0 +1,24 @@
+package tv.codely.mooc.courses.application;
+
+import tv.codely.shared.domain.ListMother;
+
+import java.util.Collections;
+import java.util.List;
+
+public final class CoursesResponseMother {
+ public static CoursesResponse create(List courses) {
+ return new CoursesResponse(courses);
+ }
+
+ public static CoursesResponse random() {
+ return create(ListMother.random(CourseResponseMother::random));
+ }
+
+ public static CoursesResponse times(int times) {
+ return create(ListMother.create(times, CourseResponseMother::random));
+ }
+
+ public static CoursesResponse empty() {
+ return create(Collections.emptyList());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandHandlerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandHandlerShould.java
new file mode 100644
index 0000000..efdd3f7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandHandlerShould.java
@@ -0,0 +1,33 @@
+package tv.codely.mooc.courses.application.create;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses.CoursesModuleUnitTestCase;
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseCreatedDomainEventMother;
+import tv.codely.mooc.courses.domain.CourseMother;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+final class CreateCourseCommandHandlerShould extends CoursesModuleUnitTestCase {
+ private CreateCourseCommandHandler handler;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ handler = new CreateCourseCommandHandler(new CourseCreator(repository, eventBus));
+ }
+
+ @Test
+ void create_a_valid_course() {
+ CreateCourseCommand command = CreateCourseCommandMother.random();
+
+ Course course = CourseMother.fromRequest(command);
+ CourseCreatedDomainEvent domainEvent = CourseCreatedDomainEventMother.fromCourse(course);
+
+ handler.handle(command);
+
+ shouldHaveSaved(course);
+ shouldHavePublished(domainEvent);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandMother.java
new file mode 100644
index 0000000..f4dad05
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/create/CreateCourseCommandMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.application.create;
+
+import tv.codely.mooc.courses.domain.*;
+
+public final class CreateCourseCommandMother {
+ public static CreateCourseCommand create(CourseId id, CourseName name, CourseDuration duration) {
+ return new CreateCourseCommand(id.value(), name.value(), duration.value());
+ }
+
+ public static CreateCourseCommand random() {
+ return create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryMother.java
new file mode 100644
index 0000000..c589326
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/application/search_last/SearchLastCoursesQueryMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.application.search_last;
+
+import tv.codely.shared.domain.IntegerMother;
+
+public final class SearchLastCoursesQueryMother {
+ public static SearchLastCoursesQuery create(Integer total) {
+ return new SearchLastCoursesQuery(total);
+ }
+
+ public static SearchLastCoursesQuery random() {
+ return create(IntegerMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseCreatedDomainEventMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseCreatedDomainEventMother.java
new file mode 100644
index 0000000..e73f2d4
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseCreatedDomainEventMother.java
@@ -0,0 +1,17 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+public final class CourseCreatedDomainEventMother {
+ public static CourseCreatedDomainEvent create(CourseId id, CourseName name, CourseDuration duration) {
+ return new CourseCreatedDomainEvent(id.value(), name.value(), duration.value());
+ }
+
+ public static CourseCreatedDomainEvent fromCourse(Course course) {
+ return create(course.id(), course.name(), course.duration());
+ }
+
+ public static CourseCreatedDomainEvent random() {
+ return create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseDurationMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseDurationMother.java
new file mode 100644
index 0000000..d9ed986
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseDurationMother.java
@@ -0,0 +1,20 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.IntegerMother;
+import tv.codely.shared.domain.RandomElementPicker;
+
+public final class CourseDurationMother {
+ public static CourseDuration create(String value) {
+ return new CourseDuration(value);
+ }
+
+ public static CourseDuration random() {
+ return create(
+ String.format(
+ "%s %s",
+ IntegerMother.random(),
+ RandomElementPicker.from("months", "years", "days", "hours", "minutes", "seconds")
+ )
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseIdMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseIdMother.java
new file mode 100644
index 0000000..76bf0d9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseIdMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class CourseIdMother {
+ public static CourseId create(String value) {
+ return new CourseId(value);
+ }
+
+ public static CourseId random() {
+ return create(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseMother.java
new file mode 100644
index 0000000..20f225e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseMother.java
@@ -0,0 +1,21 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.mooc.courses.application.create.CreateCourseCommand;
+
+public final class CourseMother {
+ public static Course create(CourseId id, CourseName name, CourseDuration duration) {
+ return new Course(id, name, duration);
+ }
+
+ public static Course fromRequest(CreateCourseCommand request) {
+ return create(
+ CourseIdMother.create(request.id()),
+ CourseNameMother.create(request.name()),
+ CourseDurationMother.create(request.duration())
+ );
+ }
+
+ public static Course random() {
+ return create(CourseIdMother.random(), CourseNameMother.random(), CourseDurationMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseNameMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseNameMother.java
new file mode 100644
index 0000000..1ca25f8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/domain/CourseNameMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses.domain;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class CourseNameMother {
+ public static CourseName create(String value) {
+ return new CourseName(value);
+ }
+
+ public static CourseName random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepositoryShould.java
new file mode 100644
index 0000000..04e3d6a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/InMemoryCourseRepositoryShould.java
@@ -0,0 +1,35 @@
+package tv.codely.mooc.courses.infrastructure.persistence;
+
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses.CoursesModuleInfrastructureTestCase;
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseIdMother;
+import tv.codely.mooc.courses.domain.CourseMother;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+final class InMemoryCourseRepositoryShould extends CoursesModuleInfrastructureTestCase {
+ @Test
+ void save_a_course() {
+ Course course = CourseMother.random();
+
+ inMemoryCourseRepository.save(course);
+ }
+
+ @Test
+ void return_an_existing_course() {
+ Course course = CourseMother.random();
+
+ inMemoryCourseRepository.save(course);
+
+ assertEquals(Optional.of(course), inMemoryCourseRepository.search(course.id()));
+ }
+
+ @Test
+ void not_return_a_non_existing_course() {
+ assertFalse(inMemoryCourseRepository.search(CourseIdMother.random()).isPresent());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepositoryShould.java
new file mode 100644
index 0000000..45b8a1b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses/infrastructure/persistence/MySqlCourseRepositoryShould.java
@@ -0,0 +1,37 @@
+package tv.codely.mooc.courses.infrastructure.persistence;
+
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses.CoursesModuleInfrastructureTestCase;
+import tv.codely.mooc.courses.domain.Course;
+import tv.codely.mooc.courses.domain.CourseIdMother;
+import tv.codely.mooc.courses.domain.CourseMother;
+
+import jakarta.transaction.Transactional;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+@Transactional
+class MySqlCourseRepositoryShould extends CoursesModuleInfrastructureTestCase {
+ @Test
+ void save_a_course() {
+ Course course = CourseMother.random();
+
+ mySqlCourseRepository.save(course);
+ }
+
+ @Test
+ void return_an_existing_course() {
+ Course course = CourseMother.random();
+
+ mySqlCourseRepository.save(course);
+
+ assertEquals(Optional.of(course), mySqlCourseRepository.search(course.id()));
+ }
+
+ @Test
+ void not_return_a_non_existing_course() {
+ assertFalse(mySqlCourseRepository.search(CourseIdMother.random()).isPresent());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleInfrastructureTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleInfrastructureTestCase.java
new file mode 100644
index 0000000..18978bb
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleInfrastructureTestCase.java
@@ -0,0 +1,10 @@
+package tv.codely.mooc.courses_counter;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.mooc.MoocContextInfrastructureTestCase;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterRepository;
+
+public abstract class CoursesCounterModuleInfrastructureTestCase extends MoocContextInfrastructureTestCase {
+ @Autowired
+ protected CoursesCounterRepository repository;
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleUnitTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleUnitTestCase.java
new file mode 100644
index 0000000..8c06e7f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/CoursesCounterModuleUnitTestCase.java
@@ -0,0 +1,34 @@
+package tv.codely.mooc.courses_counter;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.Mockito;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterRepository;
+import tv.codely.shared.infrastructure.UnitTestCase;
+
+import java.util.Optional;
+
+import static org.mockito.Mockito.*;
+
+public abstract class CoursesCounterModuleUnitTestCase extends UnitTestCase {
+ protected CoursesCounterRepository repository;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ repository = mock(CoursesCounterRepository.class);
+ }
+
+ public void shouldSearch(CoursesCounter course) {
+ Mockito.when(repository.search()).thenReturn(Optional.of(course));
+ }
+
+ public void shouldSearch() {
+ Mockito.when(repository.search()).thenReturn(Optional.empty());
+ }
+
+ public void shouldHaveSaved(CoursesCounter course) {
+ verify(repository, atLeastOnce()).save(course);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponseMother.java
new file mode 100644
index 0000000..4b38949
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/CoursesCounterResponseMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import tv.codely.shared.domain.IntegerMother;
+
+final class CoursesCounterResponseMother {
+ public static CoursesCounterResponse create(Integer value) {
+ return new CoursesCounterResponse(value);
+ }
+
+ public static CoursesCounterResponse random() {
+ return create(IntegerMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandlerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandlerShould.java
new file mode 100644
index 0000000..1065514
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/find/FindCoursesCounterQueryHandlerShould.java
@@ -0,0 +1,42 @@
+package tv.codely.mooc.courses_counter.application.find;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses_counter.CoursesCounterModuleUnitTestCase;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterMother;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterNotInitialized;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+final class FindCoursesCounterQueryHandlerShould extends CoursesCounterModuleUnitTestCase {
+ FindCoursesCounterQueryHandler handler;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ handler = new FindCoursesCounterQueryHandler(new CoursesCounterFinder(repository));
+ }
+
+ @Test
+ void it_should_find_an_existing_courses_counter() {
+ CoursesCounter counter = CoursesCounterMother.random();
+ FindCoursesCounterQuery query = new FindCoursesCounterQuery();
+ CoursesCounterResponse response = CoursesCounterResponseMother.create(counter.total().value());
+
+ shouldSearch(counter);
+
+ assertEquals(response, handler.handle(query));
+ }
+
+ @Test
+ void it_should_throw_an_exception_when_courses_counter_does_not_exists() {
+ FindCoursesCounterQuery query = new FindCoursesCounterQuery();
+
+ shouldSearch();
+
+ assertThrows(CoursesCounterNotInitialized.class, () -> handler.handle(query));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreatedShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreatedShould.java
new file mode 100644
index 0000000..13d6218
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/application/increment/IncrementCoursesCounterOnCourseCreatedShould.java
@@ -0,0 +1,66 @@
+package tv.codely.mooc.courses_counter.application.increment;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses.domain.CourseCreatedDomainEventMother;
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseIdMother;
+import tv.codely.mooc.courses_counter.CoursesCounterModuleUnitTestCase;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterMother;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+final class IncrementCoursesCounterOnCourseCreatedShould extends CoursesCounterModuleUnitTestCase {
+ IncrementCoursesCounterOnCourseCreated subscriber;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ subscriber = new IncrementCoursesCounterOnCourseCreated(
+ new CoursesCounterIncrementer(repository, uuidGenerator)
+ );
+ }
+
+ @Test
+ void it_should_initialize_a_new_counter() {
+ CourseCreatedDomainEvent event = CourseCreatedDomainEventMother.random();
+
+ CourseId courseId = CourseIdMother.create(event.aggregateId());
+ CoursesCounter newCounter = CoursesCounterMother.withOne(courseId);
+
+ shouldSearch();
+ shouldGenerateUuid(newCounter.id().value());
+
+ subscriber.on(event);
+
+ shouldHaveSaved(newCounter);
+ }
+
+ @Test
+ void it_should_increment_an_existing_counter() {
+ CourseCreatedDomainEvent event = CourseCreatedDomainEventMother.random();
+
+ CourseId courseId = CourseIdMother.create(event.aggregateId());
+ CoursesCounter existingCounter = CoursesCounterMother.random();
+ CoursesCounter incrementedCounter = CoursesCounterMother.incrementing(existingCounter, courseId);
+
+ shouldSearch(existingCounter);
+
+ subscriber.on(event);
+
+ shouldHaveSaved(incrementedCounter);
+ }
+
+ @Test
+ void it_should_not_increment_an_already_incremented_course() {
+ CourseCreatedDomainEvent event = CourseCreatedDomainEventMother.random();
+
+ CourseId courseId = CourseIdMother.create(event.aggregateId());
+ CoursesCounter existingCounter = CoursesCounterMother.withOne(courseId);
+
+ shouldSearch(existingCounter);
+
+ subscriber.on(event);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterIdMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterIdMother.java
new file mode 100644
index 0000000..596a118
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterIdMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class CoursesCounterIdMother {
+ public static CoursesCounterId create(String value) {
+ return new CoursesCounterId(value);
+ }
+
+ public static CoursesCounterId random() {
+ return create(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterMother.java
new file mode 100644
index 0000000..9e5a27f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterMother.java
@@ -0,0 +1,43 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.mooc.courses.domain.CourseId;
+import tv.codely.mooc.courses.domain.CourseIdMother;
+import tv.codely.shared.domain.ListMother;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class CoursesCounterMother {
+ public static CoursesCounter create(
+ CoursesCounterId id,
+ CoursesCounterTotal total,
+ List existingCourses
+ ) {
+ return new CoursesCounter(id, total, existingCourses);
+ }
+
+ public static CoursesCounter withOne(CourseId courseId) {
+ return create(CoursesCounterIdMother.random(), CoursesCounterTotalMother.one(), ListMother.one(courseId));
+ }
+
+ public static CoursesCounter random() {
+ List existingCourses = ListMother.random(CourseIdMother::random);
+
+ return create(
+ CoursesCounterIdMother.random(),
+ CoursesCounterTotalMother.create(existingCourses.size()),
+ existingCourses
+ );
+ }
+
+ public static CoursesCounter incrementing(CoursesCounter existingCounter, CourseId courseId) {
+ List existingCourses = new ArrayList<>(existingCounter.existingCourses());
+ existingCourses.add(courseId);
+
+ return create(
+ existingCounter.id(),
+ CoursesCounterTotalMother.create(existingCounter.total().value() + 1),
+ existingCourses
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterTotalMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterTotalMother.java
new file mode 100644
index 0000000..b8c22cb
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/domain/CoursesCounterTotalMother.java
@@ -0,0 +1,17 @@
+package tv.codely.mooc.courses_counter.domain;
+
+import tv.codely.shared.domain.IntegerMother;
+
+public final class CoursesCounterTotalMother {
+ public static CoursesCounterTotal create(Integer value) {
+ return new CoursesCounterTotal(value);
+ }
+
+ public static CoursesCounterTotal random() {
+ return create(IntegerMother.random());
+ }
+
+ public static CoursesCounterTotal one() {
+ return create(1);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepositoryShould.java
new file mode 100644
index 0000000..fabdfe8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/courses_counter/infrastructure/persistence/MySqlCoursesCounterRepositoryShould.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.courses_counter.infrastructure.persistence;
+
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses_counter.CoursesCounterModuleInfrastructureTestCase;
+import tv.codely.mooc.courses_counter.domain.CoursesCounter;
+import tv.codely.mooc.courses_counter.domain.CoursesCounterMother;
+
+import jakarta.transaction.Transactional;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+@Transactional
+class MySqlCoursesCounterRepositoryShould extends CoursesCounterModuleInfrastructureTestCase {
+ @Test
+ void return_an_existing_courses_counter() {
+ CoursesCounter counter = CoursesCounterMother.random();
+
+ repository.save(counter);
+
+ assertEquals(Optional.of(counter), repository.search());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/NotificationsModuleUnitTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/NotificationsModuleUnitTestCase.java
new file mode 100644
index 0000000..6b12480
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/NotificationsModuleUnitTestCase.java
@@ -0,0 +1,33 @@
+package tv.codely.mooc.notifications.application;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.ArgumentCaptor;
+import tv.codely.mooc.notifications.domain.Email;
+import tv.codely.mooc.notifications.domain.EmailSender;
+import tv.codely.shared.infrastructure.UnitTestCase;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.*;
+
+public abstract class NotificationsModuleUnitTestCase extends UnitTestCase {
+ protected EmailSender sender;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ sender = mock(EmailSender.class);
+ }
+
+ public void shouldHaveSentEmail(Email email) {
+ ArgumentCaptor argument = ArgumentCaptor.forClass(Email.class);
+
+ verify(sender, atLeastOnce()).send(argument.capture());
+
+ List emails = argument.getAllValues();
+
+ assertTrue(emails.contains(email));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandlerShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandlerShould.java
new file mode 100644
index 0000000..8134c05
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandHandlerShould.java
@@ -0,0 +1,102 @@
+package tv.codely.mooc.notifications.application.send_new_courses_newsletter;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.mooc.courses.application.CoursesResponseMother;
+import tv.codely.mooc.courses.application.search_last.SearchLastCoursesQuery;
+import tv.codely.mooc.courses.application.search_last.SearchLastCoursesQueryMother;
+import tv.codely.mooc.notifications.application.NotificationsModuleUnitTestCase;
+import tv.codely.mooc.notifications.domain.NewCoursesNewsletter;
+import tv.codely.mooc.notifications.domain.NewCoursesNewsletterEmailSent;
+import tv.codely.mooc.notifications.domain.NewCoursesNewsletterEmailSentMother;
+import tv.codely.mooc.notifications.domain.NewCoursesNewsletterMother;
+import tv.codely.mooc.students.application.StudentResponse;
+import tv.codely.mooc.students.application.StudentResponseMother;
+import tv.codely.mooc.students.application.StudentsResponse;
+import tv.codely.mooc.students.application.StudentsResponseMother;
+import tv.codely.mooc.students.application.search_all.SearchAllStudentsQuery;
+import tv.codely.mooc.students.application.search_all.SearchAllStudentsQueryMother;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+import tv.codely.shared.domain.bus.query.QueryHandlerExecutionError;
+
+import java.util.Arrays;
+
+final class SendNewCoursesNewsletterCommandHandlerShould extends NotificationsModuleUnitTestCase {
+ SendNewCoursesNewsletterCommandHandler handler;
+
+ @BeforeEach
+ protected void setUp() {
+ super.setUp();
+
+ handler = new SendNewCoursesNewsletterCommandHandler(
+ new NewCoursesNewsletterSender(queryBus, uuidGenerator, sender, eventBus)
+ );
+ }
+
+ @Test
+ void not_send_the_newsletter_when_there_are_no_courses() throws QueryHandlerExecutionError, CommandHandlerExecutionError {
+ SendNewCoursesNewsletterCommand command = SendNewCoursesNewsletterCommandMother.random();
+
+ SearchLastCoursesQuery coursesQuery = SearchLastCoursesQueryMother.create(3);
+ CoursesResponse coursesResponse = CoursesResponseMother.empty();
+
+ shouldAsk(coursesQuery, coursesResponse);
+
+ handler.handle(command);
+ }
+
+ @Test
+ void not_send_the_newsletter_when_there_are_no_students() throws QueryHandlerExecutionError, CommandHandlerExecutionError {
+ SendNewCoursesNewsletterCommand command = SendNewCoursesNewsletterCommandMother.random();
+
+ SearchLastCoursesQuery coursesQuery = SearchLastCoursesQueryMother.create(3);
+ CoursesResponse coursesResponse = CoursesResponseMother.random();
+
+ SearchAllStudentsQuery studentsQuery = SearchAllStudentsQueryMother.random();
+ StudentsResponse studentsResponse = StudentsResponseMother.empty();
+
+ shouldAsk(coursesQuery, coursesResponse);
+ shouldAsk(studentsQuery, studentsResponse);
+
+ handler.handle(command);
+ }
+
+ @Test
+ void send_the_new_courses_newsletter() throws QueryHandlerExecutionError, CommandHandlerExecutionError {
+ SendNewCoursesNewsletterCommand command = SendNewCoursesNewsletterCommandMother.random();
+
+ SearchLastCoursesQuery coursesQuery = SearchLastCoursesQueryMother.create(3);
+ CoursesResponse coursesResponse = CoursesResponseMother.times(3);
+
+ SearchAllStudentsQuery studentsQuery = SearchAllStudentsQueryMother.random();
+ StudentResponse student = StudentResponseMother.random();
+ StudentResponse otherStudent = StudentResponseMother.random();
+ StudentsResponse studentsResponse = StudentsResponseMother.create(Arrays.asList(student, otherStudent));
+
+ NewCoursesNewsletter newsletter = NewCoursesNewsletterMother.create(student, coursesResponse);
+ NewCoursesNewsletter otherNewsletter = NewCoursesNewsletterMother.create(otherStudent, coursesResponse);
+
+ NewCoursesNewsletterEmailSent domainEvent = NewCoursesNewsletterEmailSentMother.create(
+ newsletter.id(),
+ student.id()
+ );
+ NewCoursesNewsletterEmailSent otherDomainEvent = NewCoursesNewsletterEmailSentMother.create(
+ otherNewsletter.id(),
+ otherStudent.id()
+ );
+
+ shouldAsk(coursesQuery, coursesResponse);
+ shouldAsk(studentsQuery, studentsResponse);
+
+ shouldGenerateUuids(newsletter.id().value(), otherNewsletter.id().value());
+
+ handler.handle(command);
+
+ shouldHaveSentEmail(newsletter);
+ shouldHavePublished(domainEvent);
+
+ shouldHaveSentEmail(otherNewsletter);
+ shouldHavePublished(otherDomainEvent);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandMother.java
new file mode 100644
index 0000000..f91caff
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/application/send_new_courses_newsletter/SendNewCoursesNewsletterCommandMother.java
@@ -0,0 +1,9 @@
+package tv.codely.mooc.notifications.application.send_new_courses_newsletter;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class SendNewCoursesNewsletterCommandMother {
+ public static SendNewCoursesNewsletterCommand random() {
+ return new SendNewCoursesNewsletterCommand(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/EmailIdMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/EmailIdMother.java
new file mode 100644
index 0000000..0211444
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/EmailIdMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class EmailIdMother {
+ public static EmailId create(String value) {
+ return new EmailId(value);
+ }
+
+ public static EmailId random() {
+ return create(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSentMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSentMother.java
new file mode 100644
index 0000000..97afd0f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterEmailSentMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.mooc.students.domain.StudentIdMother;
+
+public final class NewCoursesNewsletterEmailSentMother {
+ public static NewCoursesNewsletterEmailSent create(EmailId id, String studentId) {
+ return new NewCoursesNewsletterEmailSent(id.value(), studentId);
+ }
+
+ public static NewCoursesNewsletterEmailSent random() {
+ return create(EmailIdMother.random(), StudentIdMother.random().value());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterMother.java
new file mode 100644
index 0000000..a5ff7f5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/notifications/domain/NewCoursesNewsletterMother.java
@@ -0,0 +1,20 @@
+package tv.codely.mooc.notifications.domain;
+
+import tv.codely.mooc.courses.application.CoursesResponse;
+import tv.codely.mooc.courses.application.CoursesResponseMother;
+import tv.codely.mooc.students.application.StudentResponse;
+import tv.codely.mooc.students.application.StudentResponseMother;
+
+public final class NewCoursesNewsletterMother {
+ public static NewCoursesNewsletter create(EmailId id, StudentResponse student, CoursesResponse courses) {
+ return new NewCoursesNewsletter(id, student, courses);
+ }
+
+ public static NewCoursesNewsletter create(StudentResponse student, CoursesResponse courses) {
+ return new NewCoursesNewsletter(EmailIdMother.random(), student, courses);
+ }
+
+ public static NewCoursesNewsletter random() {
+ return create(EmailIdMother.random(), StudentResponseMother.random(), CoursesResponseMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/mysql/MySqlEventBusShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/mysql/MySqlEventBusShould.java
new file mode 100644
index 0000000..17dc703
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/mysql/MySqlEventBusShould.java
@@ -0,0 +1,34 @@
+package tv.codely.mooc.shared.infrastructure.bus.event.mysql;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.mooc.MoocContextInfrastructureTestCase;
+import tv.codely.mooc.courses.domain.CourseCreatedDomainEventMother;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlDomainEventsConsumer;
+import tv.codely.shared.infrastructure.bus.event.mysql.MySqlEventBus;
+
+import jakarta.transaction.Transactional;
+import java.util.Collections;
+
+@Transactional
+class MySqlEventBusShould extends MoocContextInfrastructureTestCase {
+ @Autowired
+ private MySqlEventBus eventBus;
+ @Autowired
+ private MySqlDomainEventsConsumer consumer;
+
+ @Test
+ void publish_and_consume_domain_events_from_msql() throws InterruptedException {
+ CourseCreatedDomainEvent domainEvent = CourseCreatedDomainEventMother.random();
+
+ eventBus.publish(Collections.singletonList(domainEvent));
+
+ Thread consumerProcess = new Thread(() -> consumer.consume());
+ consumerProcess.start();
+
+ Thread.sleep(100);
+
+ consumer.stop();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/RabbitMqEventBusShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/RabbitMqEventBusShould.java
new file mode 100644
index 0000000..e559256
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/RabbitMqEventBusShould.java
@@ -0,0 +1,53 @@
+package tv.codely.mooc.shared.infrastructure.bus.event.rabbitmq;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.mooc.MoocContextInfrastructureTestCase;
+import tv.codely.mooc.courses.domain.CourseCreatedDomainEventMother;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+import tv.codely.shared.infrastructure.bus.event.DomainEventSubscriberInformation;
+import tv.codely.shared.infrastructure.bus.event.DomainEventSubscribersInformation;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqDomainEventsConsumer;
+import tv.codely.shared.infrastructure.bus.event.rabbitmq.RabbitMqEventBus;
+
+import java.util.Collections;
+import java.util.HashMap;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public final class RabbitMqEventBusShould extends MoocContextInfrastructureTestCase {
+ @Autowired
+ private RabbitMqEventBus eventBus;
+ @Autowired
+ private RabbitMqDomainEventsConsumer consumer;
+ @Autowired
+ private TestAllWorksOnRabbitMqEventsPublished subscriber;
+
+ @BeforeEach
+ protected void setUp() {
+ subscriber.hasBeenExecuted = false;
+
+ consumer.withSubscribersInformation(
+ new DomainEventSubscribersInformation(
+ new HashMap, DomainEventSubscriberInformation>() {{
+ put(TestAllWorksOnRabbitMqEventsPublished.class, new DomainEventSubscriberInformation(
+ TestAllWorksOnRabbitMqEventsPublished.class,
+ Collections.singletonList(CourseCreatedDomainEvent.class)
+ ));
+ }}
+ )
+ );
+ }
+
+ @Test
+ void publish_and_consume_domain_events_from_rabbitmq() throws Exception {
+ CourseCreatedDomainEvent domainEvent = CourseCreatedDomainEventMother.random();
+
+ eventBus.publish(Collections.singletonList(domainEvent));
+
+ consumer.consume();
+
+ eventually(() -> assertTrue(subscriber.hasBeenExecuted));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/TestAllWorksOnRabbitMqEventsPublished.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/TestAllWorksOnRabbitMqEventsPublished.java
new file mode 100644
index 0000000..8b1981d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/shared/infrastructure/bus/event/rabbitmq/TestAllWorksOnRabbitMqEventsPublished.java
@@ -0,0 +1,15 @@
+package tv.codely.mooc.shared.infrastructure.bus.event.rabbitmq;
+
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.DomainEventSubscriber;
+import tv.codely.shared.domain.course.CourseCreatedDomainEvent;
+
+@Service
+@DomainEventSubscriber({CourseCreatedDomainEvent.class})
+public final class TestAllWorksOnRabbitMqEventsPublished {
+ public Boolean hasBeenExecuted = false;
+
+ public void on(CourseCreatedDomainEvent event) {
+ hasBeenExecuted = true;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/StepsModuleInfrastructureTestCase.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/StepsModuleInfrastructureTestCase.java
new file mode 100644
index 0000000..cc0c90d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/StepsModuleInfrastructureTestCase.java
@@ -0,0 +1,10 @@
+package tv.codely.mooc.steps;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import tv.codely.mooc.MoocContextInfrastructureTestCase;
+import tv.codely.mooc.steps.domain.StepRepository;
+
+public abstract class StepsModuleInfrastructureTestCase extends MoocContextInfrastructureTestCase {
+ @Autowired
+ protected StepRepository repository;
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepIdMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepIdMother.java
new file mode 100644
index 0000000..7747f37
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepIdMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class StepIdMother {
+ public static StepId create(String value) {
+ return new StepId(value);
+ }
+
+ public static StepId random() {
+ return create(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepTitleMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepTitleMother.java
new file mode 100644
index 0000000..571bc13
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/StepTitleMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class StepTitleMother {
+ public static StepTitle create(String value) {
+ return new StepTitle(value);
+ }
+
+ public static StepTitle random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepMother.java
new file mode 100644
index 0000000..46fcb75
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepMother.java
@@ -0,0 +1,16 @@
+package tv.codely.mooc.steps.domain.challenge;
+
+import tv.codely.mooc.steps.domain.StepId;
+import tv.codely.mooc.steps.domain.StepIdMother;
+import tv.codely.mooc.steps.domain.StepTitle;
+import tv.codely.mooc.steps.domain.StepTitleMother;
+
+public final class ChallengeStepMother {
+ public static ChallengeStep create(StepId id, StepTitle title, ChallengeStepStatement statement) {
+ return new ChallengeStep(id, title, statement);
+ }
+
+ public static ChallengeStep random() {
+ return create(StepIdMother.random(), StepTitleMother.random(), ChallengeStepStatementMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatementMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatementMother.java
new file mode 100644
index 0000000..8e85970
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/challenge/ChallengeStepStatementMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain.challenge;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class ChallengeStepStatementMother {
+ public static ChallengeStepStatement create(String value) {
+ return new ChallengeStepStatement(value);
+ }
+
+ public static ChallengeStepStatement random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepMother.java
new file mode 100644
index 0000000..3cb9432
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepMother.java
@@ -0,0 +1,23 @@
+package tv.codely.mooc.steps.domain.video;
+
+import tv.codely.mooc.steps.domain.StepId;
+import tv.codely.mooc.steps.domain.StepIdMother;
+import tv.codely.mooc.steps.domain.StepTitle;
+import tv.codely.mooc.steps.domain.StepTitleMother;
+import tv.codely.shared.domain.VideoUrl;
+import tv.codely.shared.domain.VideoUrlMother;
+
+public final class VideoStepMother {
+ public static VideoStep create(StepId id, StepTitle title, VideoUrl videoUrl, VideoStepText text) {
+ return new VideoStep(id, title, videoUrl, text);
+ }
+
+ public static VideoStep random() {
+ return create(
+ StepIdMother.random(),
+ StepTitleMother.random(),
+ VideoUrlMother.random(),
+ VideoStepTextMother.random()
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepTextMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepTextMother.java
new file mode 100644
index 0000000..7dca6fa
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/domain/video/VideoStepTextMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.steps.domain.video;
+
+import tv.codely.shared.domain.WordMother;
+
+public final class VideoStepTextMother {
+ public static VideoStepText create(String value) {
+ return new VideoStepText(value);
+ }
+
+ public static VideoStepText random() {
+ return create(WordMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepositoryShould.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepositoryShould.java
new file mode 100644
index 0000000..88c684e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/steps/infrastructure/persistence/MySqlStepRepositoryShould.java
@@ -0,0 +1,44 @@
+package tv.codely.mooc.steps.infrastructure.persistence;
+
+import org.junit.jupiter.api.Test;
+import tv.codely.mooc.steps.StepsModuleInfrastructureTestCase;
+import tv.codely.mooc.steps.domain.Step;
+import tv.codely.mooc.steps.domain.StepIdMother;
+import tv.codely.mooc.steps.domain.challenge.ChallengeStepMother;
+import tv.codely.mooc.steps.domain.video.VideoStepMother;
+
+import jakarta.transaction.Transactional;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+@Transactional
+class MySqlStepRepositoryShould extends StepsModuleInfrastructureTestCase {
+ @Test
+ void save_a_step() {
+ for (Step step : steps()) {
+ repository.save(step);
+ }
+ }
+
+ @Test
+ void return_an_existing_step() {
+ for (Step step : steps()) {
+ repository.save(step);
+
+ assertEquals(Optional.of(step), repository.search(step.id()));
+ }
+ }
+
+ @Test
+ void not_return_a_non_existing_course() {
+ assertFalse(repository.search(StepIdMother.random()).isPresent());
+ }
+
+ private List extends Step> steps() {
+ return Arrays.asList(ChallengeStepMother.random(), VideoStepMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentResponseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentResponseMother.java
new file mode 100644
index 0000000..b29fb25
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentResponseMother.java
@@ -0,0 +1,16 @@
+package tv.codely.mooc.students.application;
+
+import tv.codely.mooc.students.domain.StudentId;
+import tv.codely.mooc.students.domain.StudentIdMother;
+import tv.codely.shared.domain.EmailMother;
+import tv.codely.shared.domain.WordMother;
+
+public final class StudentResponseMother {
+ public static StudentResponse create(StudentId id, String name, String surname, String email) {
+ return new StudentResponse(id.value(), name, surname, email);
+ }
+
+ public static StudentResponse random() {
+ return create(StudentIdMother.random(), WordMother.random(), WordMother.random(), EmailMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentsResponseMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentsResponseMother.java
new file mode 100644
index 0000000..89e879a
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/StudentsResponseMother.java
@@ -0,0 +1,20 @@
+package tv.codely.mooc.students.application;
+
+import tv.codely.shared.domain.ListMother;
+
+import java.util.Collections;
+import java.util.List;
+
+public final class StudentsResponseMother {
+ public static StudentsResponse create(List courses) {
+ return new StudentsResponse(courses);
+ }
+
+ public static StudentsResponse random() {
+ return create(ListMother.random(StudentResponseMother::random));
+ }
+
+ public static StudentsResponse empty() {
+ return create(Collections.emptyList());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryMother.java
new file mode 100644
index 0000000..93a0e28
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/application/search_all/SearchAllStudentsQueryMother.java
@@ -0,0 +1,7 @@
+package tv.codely.mooc.students.application.search_all;
+
+public final class SearchAllStudentsQueryMother {
+ public static SearchAllStudentsQuery random() {
+ return new SearchAllStudentsQuery();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/domain/StudentIdMother.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/domain/StudentIdMother.java
new file mode 100644
index 0000000..b98ca0e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/mooc/test/tv/codely/mooc/students/domain/StudentIdMother.java
@@ -0,0 +1,13 @@
+package tv.codely.mooc.students.domain;
+
+import tv.codely.shared.domain.UuidMother;
+
+public final class StudentIdMother {
+ public static StudentId create(String value) {
+ return new StudentId(value);
+ }
+
+ public static StudentId random() {
+ return create(UuidMother.random());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/build.gradle b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/build.gradle
new file mode 100644
index 0000000..7d82dc7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/build.gradle
@@ -0,0 +1,2 @@
+dependencies {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/AggregateRoot.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/AggregateRoot.java
new file mode 100644
index 0000000..796e327
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/AggregateRoot.java
@@ -0,0 +1,23 @@
+package tv.codely.shared.domain;
+
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public abstract class AggregateRoot {
+ private List domainEvents = new ArrayList<>();
+
+ final public List pullDomainEvents() {
+ List events = domainEvents;
+
+ domainEvents = Collections.emptyList();
+
+ return events;
+ }
+
+ final protected void record(DomainEvent event) {
+ domainEvents.add(event);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/DomainError.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/DomainError.java
new file mode 100644
index 0000000..1d65456
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/DomainError.java
@@ -0,0 +1,21 @@
+package tv.codely.shared.domain;
+
+public abstract class DomainError extends RuntimeException {
+ private final String errorCode;
+ private final String errorMessage;
+
+ public DomainError(String errorCode, String errorMessage) {
+ super(errorMessage);
+
+ this.errorCode = errorCode;
+ this.errorMessage = errorMessage;
+ }
+
+ public String errorCode() {
+ return errorCode;
+ }
+
+ public String errorMessage() {
+ return errorMessage;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Identifier.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Identifier.java
new file mode 100644
index 0000000..b25970b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Identifier.java
@@ -0,0 +1,44 @@
+package tv.codely.shared.domain;
+
+import java.io.Serializable;
+import java.util.Objects;
+import java.util.UUID;
+
+public abstract class Identifier implements Serializable {
+ final protected String value;
+
+ public Identifier(String value) {
+ ensureValidUuid(value);
+
+ this.value = value;
+ }
+
+ protected Identifier() {
+ this.value = null;
+ }
+
+ public String value() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Identifier that = (Identifier) o;
+ return value.equals(that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value);
+ }
+
+ private void ensureValidUuid(String value) throws IllegalArgumentException {
+ UUID.fromString(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/IntValueObject.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/IntValueObject.java
new file mode 100644
index 0000000..4e943e5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/IntValueObject.java
@@ -0,0 +1,32 @@
+package tv.codely.shared.domain;
+
+import java.util.Objects;
+
+public abstract class IntValueObject {
+ private Integer value;
+
+ public IntValueObject(Integer value) {
+ this.value = value;
+ }
+
+ public Integer value() {
+ return value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ IntValueObject that = (IntValueObject) o;
+ return value.equals(that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Logger.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Logger.java
new file mode 100644
index 0000000..efeab9f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Logger.java
@@ -0,0 +1,15 @@
+package tv.codely.shared.domain;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+public interface Logger {
+ void info(String $message);
+ void info(String $message, HashMap $context);
+
+ void warning(String $message);
+ void warning(String $message, HashMap $context);
+
+ void critical(String $message);
+ void critical(String $message, HashMap $context);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Monitoring.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Monitoring.java
new file mode 100644
index 0000000..0958579
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Monitoring.java
@@ -0,0 +1,13 @@
+package tv.codely.shared.domain;
+
+import java.util.HashMap;
+
+public interface Monitoring {
+ void incrementCounter(int times);
+
+ void incrementGauge(int times);
+ void decrementGauge(int times);
+ void setGauge(int value);
+
+ void observeHistogram(int value, HashMap labels);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Service.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Service.java
new file mode 100644
index 0000000..d3f9566
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Service.java
@@ -0,0 +1,9 @@
+package tv.codely.shared.domain;
+
+import java.lang.annotation.*;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Inherited
+public @interface Service {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/StringValueObject.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/StringValueObject.java
new file mode 100644
index 0000000..45525e5
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/StringValueObject.java
@@ -0,0 +1,37 @@
+package tv.codely.shared.domain;
+
+import java.util.Objects;
+
+public abstract class StringValueObject {
+ private String value;
+
+ public StringValueObject(String value) {
+ this.value = value;
+ }
+
+ public String value() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return this.value();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof StringValueObject)) {
+ return false;
+ }
+ StringValueObject that = (StringValueObject) o;
+ return Objects.equals(value, that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Utils.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Utils.java
new file mode 100644
index 0000000..53dbc3e
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/Utils.java
@@ -0,0 +1,73 @@
+package tv.codely.shared.domain;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.CaseFormat;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+
+public final class Utils {
+ public static String dateToString(LocalDateTime dateTime) {
+ return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE);
+ }
+
+ public static String dateToString(Timestamp timestamp) {
+ return dateToString(timestamp.toLocalDateTime());
+ }
+
+ public static String jsonEncode(HashMap map) {
+ try {
+ return new ObjectMapper().writeValueAsString(map);
+ } catch (JsonProcessingException e) {
+ return "";
+ }
+ }
+
+ public static HashMap jsonDecode(String body) {
+ try {
+ return new ObjectMapper().readValue(body, HashMap.class);
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ public static String toSnake(String text) {
+ return CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, text);
+ }
+
+ public static String toCamel(String text) {
+ return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.UPPER_CAMEL, text);
+ }
+
+ public static String toCamelFirstLower(String text) {
+ return CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, text);
+ }
+
+ public static void retry(int numberOfRetries, long waitTimeInMillis, Runnable operation) throws Exception {
+ for (int i = 0; i < numberOfRetries; i++) {
+ try {
+ operation.run();
+ return; // Success, exit the method
+ } catch (Exception ex) {
+ System.out.println("Retry " + (i + 1) + "/" + numberOfRetries + " fail. Retrying…");
+ if (i >= numberOfRetries - 1) {
+ throw ex;
+ }
+
+ try {
+ Thread.sleep(waitTimeInMillis);
+ } catch (InterruptedException ie) {
+ Thread.currentThread().interrupt();
+
+ throw new Exception("Operation interrupted while retrying", ie);
+ }
+ }
+ }
+ }
+
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/UuidGenerator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/UuidGenerator.java
new file mode 100644
index 0000000..8348a24
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/UuidGenerator.java
@@ -0,0 +1,5 @@
+package tv.codely.shared.domain;
+
+public interface UuidGenerator {
+ String generate();
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/VideoUrl.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/VideoUrl.java
new file mode 100644
index 0000000..47aaccc
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/VideoUrl.java
@@ -0,0 +1,11 @@
+package tv.codely.shared.domain;
+
+public final class VideoUrl extends StringValueObject {
+ public VideoUrl(String value) {
+ super(value);
+ }
+
+ public VideoUrl() {
+ super(null);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/Command.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/Command.java
new file mode 100644
index 0000000..da5a342
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/Command.java
@@ -0,0 +1,4 @@
+package tv.codely.shared.domain.bus.command;
+
+public interface Command {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandBus.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandBus.java
new file mode 100644
index 0000000..dabddf2
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandBus.java
@@ -0,0 +1,5 @@
+package tv.codely.shared.domain.bus.command;
+
+public interface CommandBus {
+ void dispatch(Command command) throws CommandHandlerExecutionError;
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandler.java
new file mode 100644
index 0000000..177e09b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandler.java
@@ -0,0 +1,5 @@
+package tv.codely.shared.domain.bus.command;
+
+public interface CommandHandler {
+ void handle(T command);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandlerExecutionError.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandlerExecutionError.java
new file mode 100644
index 0000000..60d0d74
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandHandlerExecutionError.java
@@ -0,0 +1,7 @@
+package tv.codely.shared.domain.bus.command;
+
+public final class CommandHandlerExecutionError extends RuntimeException {
+ public CommandHandlerExecutionError(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandNotRegisteredError.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandNotRegisteredError.java
new file mode 100644
index 0000000..2d3af85
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/command/CommandNotRegisteredError.java
@@ -0,0 +1,7 @@
+package tv.codely.shared.domain.bus.command;
+
+public final class CommandNotRegisteredError extends Exception {
+ public CommandNotRegisteredError(Class extends Command> command) {
+ super(String.format("The command <%s> hasn't a command handler associated", command.toString()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEvent.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEvent.java
new file mode 100644
index 0000000..901955c
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEvent.java
@@ -0,0 +1,52 @@
+package tv.codely.shared.domain.bus.event;
+
+import tv.codely.shared.domain.Utils;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.UUID;
+
+public abstract class DomainEvent {
+ private String aggregateId;
+ private String eventId;
+ private String occurredOn;
+
+ public DomainEvent(String aggregateId) {
+ this.aggregateId = aggregateId;
+ this.eventId = UUID.randomUUID().toString();
+ this.occurredOn = Utils.dateToString(LocalDateTime.now());
+ }
+
+ public DomainEvent(String aggregateId, String eventId, String occurredOn) {
+ this.aggregateId = aggregateId;
+ this.eventId = eventId;
+ this.occurredOn = occurredOn;
+ }
+
+ protected DomainEvent() {
+ }
+
+ public abstract String eventName();
+
+ public abstract HashMap toPrimitives();
+
+ public abstract DomainEvent fromPrimitives(
+ String aggregateId,
+ HashMap body,
+ String eventId,
+ String occurredOn
+ );
+
+ public String aggregateId() {
+ return aggregateId;
+ }
+
+ public String eventId() {
+ return eventId;
+ }
+
+ public String occurredOn() {
+ return occurredOn;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEventSubscriber.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEventSubscriber.java
new file mode 100644
index 0000000..9895464
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/DomainEventSubscriber.java
@@ -0,0 +1,10 @@
+package tv.codely.shared.domain.bus.event;
+
+import java.lang.annotation.*;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Inherited
+public @interface DomainEventSubscriber {
+ Class extends DomainEvent>[] value();
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/EventBus.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/EventBus.java
new file mode 100644
index 0000000..cd13e0b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/event/EventBus.java
@@ -0,0 +1,7 @@
+package tv.codely.shared.domain.bus.event;
+
+import java.util.List;
+
+public interface EventBus {
+ void publish(final List events);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Query.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Query.java
new file mode 100644
index 0000000..cdc5477
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Query.java
@@ -0,0 +1,4 @@
+package tv.codely.shared.domain.bus.query;
+
+public interface Query {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryBus.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryBus.java
new file mode 100644
index 0000000..197945f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryBus.java
@@ -0,0 +1,5 @@
+package tv.codely.shared.domain.bus.query;
+
+public interface QueryBus {
+ R ask(Query query) throws QueryHandlerExecutionError;
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandler.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandler.java
new file mode 100644
index 0000000..4b56bd8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandler.java
@@ -0,0 +1,5 @@
+package tv.codely.shared.domain.bus.query;
+
+public interface QueryHandler {
+ R handle(Q query);
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandlerExecutionError.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandlerExecutionError.java
new file mode 100644
index 0000000..2e5135b
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryHandlerExecutionError.java
@@ -0,0 +1,7 @@
+package tv.codely.shared.domain.bus.query;
+
+public final class QueryHandlerExecutionError extends RuntimeException {
+ public QueryHandlerExecutionError(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryNotRegisteredError.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryNotRegisteredError.java
new file mode 100644
index 0000000..d2d380f
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/QueryNotRegisteredError.java
@@ -0,0 +1,7 @@
+package tv.codely.shared.domain.bus.query;
+
+public final class QueryNotRegisteredError extends Exception {
+ public QueryNotRegisteredError(Class extends Query> query) {
+ super(String.format("The query <%s> hasn't a query handler associated", query.toString()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Response.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Response.java
new file mode 100644
index 0000000..ac3eefc
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/bus/query/Response.java
@@ -0,0 +1,4 @@
+package tv.codely.shared.domain.bus.query;
+
+public interface Response {
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/course/CourseCreatedDomainEvent.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/course/CourseCreatedDomainEvent.java
new file mode 100644
index 0000000..23181f0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/course/CourseCreatedDomainEvent.java
@@ -0,0 +1,94 @@
+package tv.codely.shared.domain.course;
+
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.io.Serializable;
+import java.util.HashMap;
+import java.util.Objects;
+
+public final class CourseCreatedDomainEvent extends DomainEvent {
+ private final String name;
+ private final String duration;
+
+ public CourseCreatedDomainEvent() {
+ super(null);
+
+ this.name = null;
+ this.duration = null;
+ }
+
+ public CourseCreatedDomainEvent(String aggregateId, String name, String duration) {
+ super(aggregateId);
+
+ this.name = name;
+ this.duration = duration;
+ }
+
+ public CourseCreatedDomainEvent(
+ String aggregateId,
+ String eventId,
+ String occurredOn,
+ String name,
+ String duration
+ ) {
+ super(aggregateId, eventId, occurredOn);
+
+ this.name = name;
+ this.duration = duration;
+ }
+
+ @Override
+ public String eventName() {
+ return "course.created";
+ }
+
+ @Override
+ public HashMap toPrimitives() {
+ return new HashMap() {{
+ put("name", name);
+ put("duration", duration);
+ }};
+ }
+
+ @Override
+ public CourseCreatedDomainEvent fromPrimitives(
+ String aggregateId,
+ HashMap body,
+ String eventId,
+ String occurredOn
+ ) {
+ return new CourseCreatedDomainEvent(
+ aggregateId,
+ eventId,
+ occurredOn,
+ (String) body.get("name"),
+ (String) body.get("duration")
+ );
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String duration() {
+ return duration;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ CourseCreatedDomainEvent that = (CourseCreatedDomainEvent) o;
+ return name.equals(that.name) &&
+ duration.equals(that.duration);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(name, duration);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Criteria.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Criteria.java
new file mode 100644
index 0000000..aea18af
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Criteria.java
@@ -0,0 +1,54 @@
+package tv.codely.shared.domain.criteria;
+
+import java.util.Optional;
+
+public final class Criteria {
+ private final Filters filters;
+ private final Order order;
+ private final Optional limit;
+ private final Optional offset;
+
+ public Criteria(Filters filters, Order order, Optional limit, Optional offset) {
+ this.filters = filters;
+ this.order = order;
+ this.limit = limit;
+ this.offset = offset;
+ }
+
+ public Criteria(Filters filters, Order order) {
+ this.filters = filters;
+ this.order = order;
+ this.limit = Optional.empty();
+ this.offset = Optional.empty();
+ }
+
+ public Filters filters() {
+ return filters;
+ }
+
+ public Order order() {
+ return order;
+ }
+
+ public Optional limit() {
+ return limit;
+ }
+
+ public Optional offset() {
+ return offset;
+ }
+
+ public boolean hasFilters() {
+ return filters.filters().size() > 0;
+ }
+
+ public String serialize() {
+ return String.format(
+ "%s~~%s~~%s~~%s",
+ filters.serialize(),
+ order.serialize(),
+ offset.orElse(0),
+ limit.orElse(0)
+ );
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filter.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filter.java
new file mode 100644
index 0000000..b3244a6
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filter.java
@@ -0,0 +1,47 @@
+package tv.codely.shared.domain.criteria;
+
+import java.util.HashMap;
+
+public final class Filter {
+ private final FilterField field;
+ private final FilterOperator operator;
+ private final FilterValue value;
+
+ public Filter(FilterField field, FilterOperator operator, FilterValue value) {
+ this.field = field;
+ this.operator = operator;
+ this.value = value;
+ }
+
+ public static Filter create(String field, String operator, String value) {
+ return new Filter(
+ new FilterField(field),
+ FilterOperator.fromValue(operator.toUpperCase()),
+ new FilterValue(value)
+ );
+ }
+
+ public static Filter fromValues(HashMap values) {
+ return new Filter(
+ new FilterField(values.get("field")),
+ FilterOperator.fromValue(values.get("operator")),
+ new FilterValue(values.get("value"))
+ );
+ }
+
+ public FilterField field() {
+ return field;
+ }
+
+ public FilterOperator operator() {
+ return operator;
+ }
+
+ public FilterValue value() {
+ return value;
+ }
+
+ public String serialize() {
+ return String.format("%s.%s.%s", field.value(), operator.value(), value.value());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterField.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterField.java
new file mode 100644
index 0000000..c5f230d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterField.java
@@ -0,0 +1,9 @@
+package tv.codely.shared.domain.criteria;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class FilterField extends StringValueObject {
+ public FilterField(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterOperator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterOperator.java
new file mode 100644
index 0000000..8614f12
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterOperator.java
@@ -0,0 +1,36 @@
+package tv.codely.shared.domain.criteria;
+
+public enum FilterOperator {
+ EQUAL("="),
+ NOT_EQUAL("!="),
+ GT(">"),
+ LT("<"),
+ CONTAINS("CONTAINS"),
+ NOT_CONTAINS("NOT_CONTAINS");
+
+ private final String operator;
+
+ FilterOperator(String operator) {
+ this.operator = operator;
+ }
+
+ public static FilterOperator fromValue(String value) {
+ switch (value) {
+ case "=": return FilterOperator.EQUAL;
+ case "!=": return FilterOperator.NOT_EQUAL;
+ case ">": return FilterOperator.GT;
+ case "<": return FilterOperator.LT;
+ case "CONTAINS": return FilterOperator.CONTAINS;
+ case "NOT_CONTAINS": return FilterOperator.NOT_CONTAINS;
+ default: return null;
+ }
+ }
+
+ public boolean isPositive() {
+ return this != NOT_EQUAL && this != NOT_CONTAINS;
+ }
+
+ public String value() {
+ return operator;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterValue.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterValue.java
new file mode 100644
index 0000000..28a4f48
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/FilterValue.java
@@ -0,0 +1,9 @@
+package tv.codely.shared.domain.criteria;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class FilterValue extends StringValueObject {
+ public FilterValue(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filters.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filters.java
new file mode 100644
index 0000000..83e36ae
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Filters.java
@@ -0,0 +1,30 @@
+package tv.codely.shared.domain.criteria;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public final class Filters {
+ private final List filters;
+
+ public Filters(List filters) {
+ this.filters = filters;
+ }
+
+ public static Filters fromValues(List> filters) {
+ return new Filters(filters.stream().map(Filter::fromValues).collect(Collectors.toList()));
+ }
+
+ public static Filters none() {
+ return new Filters(Collections.emptyList());
+ }
+
+ public List filters() {
+ return filters;
+ }
+
+ public String serialize() {
+ return filters.stream().map(Filter::serialize).collect(Collectors.joining("^"));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Order.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Order.java
new file mode 100644
index 0000000..f95eaf8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/Order.java
@@ -0,0 +1,46 @@
+package tv.codely.shared.domain.criteria;
+
+import java.util.Optional;
+
+public final class Order {
+ private final OrderBy orderBy;
+ private final OrderType orderType;
+
+ public Order(OrderBy orderBy, OrderType orderType) {
+ this.orderBy = orderBy;
+ this.orderType = orderType;
+ }
+
+ public static Order fromValues(Optional orderBy, Optional orderType) {
+ return orderBy.map(order -> new Order(new OrderBy(order), OrderType.valueOf(orderType.orElse("ASC"))))
+ .orElseGet(Order::none);
+ }
+
+ public static Order none() {
+ return new Order(new OrderBy(""), OrderType.NONE);
+ }
+
+ public static Order desc(String orderBy) {
+ return new Order(new OrderBy(orderBy), OrderType.DESC);
+ }
+
+ public static Order asc(String orderBy) {
+ return new Order(new OrderBy(orderBy), OrderType.ASC);
+ }
+
+ public OrderBy orderBy() {
+ return orderBy;
+ }
+
+ public OrderType orderType() {
+ return orderType;
+ }
+
+ public boolean hasOrder() {
+ return !orderType.isNone();
+ }
+
+ public String serialize() {
+ return String.format("%s.%s", orderBy.value(), orderType.value());
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderBy.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderBy.java
new file mode 100644
index 0000000..2c1450d
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderBy.java
@@ -0,0 +1,9 @@
+package tv.codely.shared.domain.criteria;
+
+import tv.codely.shared.domain.StringValueObject;
+
+public final class OrderBy extends StringValueObject {
+ public OrderBy(String value) {
+ super(value);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderType.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderType.java
new file mode 100644
index 0000000..52deae0
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/domain/criteria/OrderType.java
@@ -0,0 +1,25 @@
+package tv.codely.shared.domain.criteria;
+
+public enum OrderType {
+ ASC("asc"),
+ DESC("desc"),
+ NONE("none");
+ private final String type;
+
+ OrderType(String type) {
+ this.type = type;
+ }
+
+ public boolean isNone() {
+ return this == NONE;
+ }
+
+ public boolean isAsc() {
+ return this == ASC;
+ }
+
+ public String value() {
+ return type;
+ }
+}
+
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/JavaUuidGenerator.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/JavaUuidGenerator.java
new file mode 100644
index 0000000..21667a8
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/JavaUuidGenerator.java
@@ -0,0 +1,14 @@
+package tv.codely.shared.infrastructure;
+
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.UuidGenerator;
+
+import java.util.UUID;
+
+@Service
+public final class JavaUuidGenerator implements UuidGenerator {
+ @Override
+ public String generate() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/CommandHandlersInformation.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/CommandHandlersInformation.java
new file mode 100644
index 0000000..5bdd8fc
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/CommandHandlersInformation.java
@@ -0,0 +1,48 @@
+package tv.codely.shared.infrastructure.bus.command;
+
+import org.reflections.Reflections;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.command.Command;
+import tv.codely.shared.domain.bus.command.CommandHandler;
+import tv.codely.shared.domain.bus.command.CommandNotRegisteredError;
+
+import java.lang.reflect.ParameterizedType;
+import java.util.HashMap;
+import java.util.Set;
+
+@Service
+public final class CommandHandlersInformation {
+ HashMap, Class extends CommandHandler>> indexedCommandHandlers;
+
+ public CommandHandlersInformation() {
+ Reflections reflections = new Reflections("tv.codely");
+ Set> classes = reflections.getSubTypesOf(CommandHandler.class);
+
+ indexedCommandHandlers = formatHandlers(classes);
+ }
+
+ public Class extends CommandHandler> search(Class extends Command> commandClass) throws CommandNotRegisteredError {
+ Class extends CommandHandler> commandHandlerClass = indexedCommandHandlers.get(commandClass);
+
+ if (null == commandHandlerClass) {
+ throw new CommandNotRegisteredError(commandClass);
+ }
+
+ return commandHandlerClass;
+ }
+
+ private HashMap, Class extends CommandHandler>> formatHandlers(
+ Set> commandHandlers
+ ) {
+ HashMap, Class extends CommandHandler>> handlers = new HashMap<>();
+
+ for (Class extends CommandHandler> handler : commandHandlers) {
+ ParameterizedType paramType = (ParameterizedType) handler.getGenericInterfaces()[0];
+ Class extends Command> commandClass = (Class extends Command>) paramType.getActualTypeArguments()[0];
+
+ handlers.put(commandClass, handler);
+ }
+
+ return handlers;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/InMemoryCommandBus.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/InMemoryCommandBus.java
new file mode 100644
index 0000000..d8b23c7
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/command/InMemoryCommandBus.java
@@ -0,0 +1,32 @@
+package tv.codely.shared.infrastructure.bus.command;
+
+import org.springframework.context.ApplicationContext;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.command.Command;
+import tv.codely.shared.domain.bus.command.CommandBus;
+import tv.codely.shared.domain.bus.command.CommandHandler;
+import tv.codely.shared.domain.bus.command.CommandHandlerExecutionError;
+
+@Service
+public final class InMemoryCommandBus implements CommandBus {
+ private final CommandHandlersInformation information;
+ private final ApplicationContext context;
+
+ public InMemoryCommandBus(CommandHandlersInformation information, ApplicationContext context) {
+ this.information = information;
+ this.context = context;
+ }
+
+ @Override
+ public void dispatch(Command command) throws CommandHandlerExecutionError {
+ try {
+ Class extends CommandHandler> commandHandlerClass = information.search(command.getClass());
+
+ CommandHandler handler = context.getBean(commandHandlerClass);
+
+ handler.handle(command);
+ } catch (Throwable error) {
+ throw new CommandHandlerExecutionError(error);
+ }
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonDeserializer.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonDeserializer.java
new file mode 100644
index 0000000..ccfa660
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonDeserializer.java
@@ -0,0 +1,46 @@
+package tv.codely.shared.infrastructure.bus.event;
+
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.Utils;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.io.Serializable;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+
+@Service
+public final class DomainEventJsonDeserializer {
+ private final DomainEventsInformation information;
+
+ public DomainEventJsonDeserializer(DomainEventsInformation information) {
+ this.information = information;
+ }
+
+ public DomainEvent deserialize(String body) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException, InstantiationException {
+ HashMap eventData = Utils.jsonDecode(body);
+ HashMap data = (HashMap) eventData.get("data");
+ HashMap attributes = (HashMap) data.get("attributes");
+ Class extends DomainEvent> domainEventClass = information.forName((String) data.get("type"));
+
+ DomainEvent nullInstance = domainEventClass.getConstructor().newInstance();
+
+ Method fromPrimitivesMethod = domainEventClass.getMethod(
+ "fromPrimitives",
+ String.class,
+ HashMap.class,
+ String.class,
+ String.class
+ );
+
+ Object domainEvent = fromPrimitivesMethod.invoke(
+ nullInstance,
+ (String) attributes.get("id"),
+ attributes,
+ (String) data.get("id"),
+ (String) data.get("occurred_on")
+ );
+
+ return (DomainEvent) domainEvent;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonSerializer.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonSerializer.java
new file mode 100644
index 0000000..adbedb9
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventJsonSerializer.java
@@ -0,0 +1,24 @@
+package tv.codely.shared.infrastructure.bus.event;
+
+import tv.codely.shared.domain.Utils;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.io.Serializable;
+import java.util.HashMap;
+
+public final class DomainEventJsonSerializer {
+ public static String serialize(DomainEvent domainEvent) {
+ HashMap attributes = domainEvent.toPrimitives();
+ attributes.put("id", domainEvent.aggregateId());
+
+ return Utils.jsonEncode(new HashMap() {{
+ put("data", new HashMap() {{
+ put("id", domainEvent.eventId());
+ put("type", domainEvent.eventName());
+ put("occurred_on", domainEvent.occurredOn());
+ put("attributes", attributes);
+ }});
+ put("meta", new HashMap());
+ }});
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscriberInformation.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscriberInformation.java
new file mode 100644
index 0000000..e14d3d2
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscriberInformation.java
@@ -0,0 +1,49 @@
+package tv.codely.shared.infrastructure.bus.event;
+
+import tv.codely.shared.domain.Utils;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.util.List;
+
+public final class DomainEventSubscriberInformation {
+ private final Class> subscriberClass;
+ private final List> subscribedEvents;
+
+ public DomainEventSubscriberInformation(
+ Class> subscriberClass,
+ List> subscribedEvents
+ ) {
+ this.subscriberClass = subscriberClass;
+ this.subscribedEvents = subscribedEvents;
+ }
+
+ public Class> subscriberClass() {
+ return subscriberClass;
+ }
+
+ public String contextName() {
+ String[] nameParts = subscriberClass.getName().split("\\.");
+
+ return nameParts[2];
+ }
+
+ public String moduleName() {
+ String[] nameParts = subscriberClass.getName().split("\\.");
+
+ return nameParts[3];
+ }
+
+ public String className() {
+ String[] nameParts = subscriberClass.getName().split("\\.");
+
+ return nameParts[nameParts.length - 1];
+ }
+
+ public List> subscribedEvents() {
+ return subscribedEvents;
+ }
+
+ public String formatRabbitMqQueueName() {
+ return String.format("codely.%s.%s.%s", contextName(), moduleName(), Utils.toSnake(className()));
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscribersInformation.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscribersInformation.java
new file mode 100644
index 0000000..e7f5b92
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventSubscribersInformation.java
@@ -0,0 +1,53 @@
+package tv.codely.shared.infrastructure.bus.event;
+
+import org.reflections.Reflections;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.DomainEventSubscriber;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Set;
+
+@Service
+public final class DomainEventSubscribersInformation {
+ HashMap, DomainEventSubscriberInformation> information;
+
+ public DomainEventSubscribersInformation(HashMap, DomainEventSubscriberInformation> information) {
+ this.information = information;
+ }
+
+ public DomainEventSubscribersInformation() {
+ this(scanDomainEventSubscribers());
+ }
+
+ private static HashMap, DomainEventSubscriberInformation> scanDomainEventSubscribers() {
+ Reflections reflections = new Reflections("tv.codely");
+ Set> subscribers = reflections.getTypesAnnotatedWith(DomainEventSubscriber.class);
+
+ HashMap, DomainEventSubscriberInformation> subscribersInformation = new HashMap<>();
+
+ for (Class> subscriberClass : subscribers) {
+ DomainEventSubscriber annotation = subscriberClass.getAnnotation(DomainEventSubscriber.class);
+
+ subscribersInformation.put(
+ subscriberClass,
+ new DomainEventSubscriberInformation(subscriberClass, Arrays.asList(annotation.value()))
+ );
+ }
+
+ return subscribersInformation;
+ }
+
+ public Collection all() {
+ return information.values();
+ }
+
+ public String[] rabbitMqFormattedNames() {
+ return information.values()
+ .stream()
+ .map(DomainEventSubscriberInformation::formatRabbitMqQueueName)
+ .distinct()
+ .toArray(String[]::new);
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventsInformation.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventsInformation.java
new file mode 100644
index 0000000..9498913
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/DomainEventsInformation.java
@@ -0,0 +1,53 @@
+package tv.codely.shared.infrastructure.bus.event;
+
+import org.reflections.Reflections;
+import tv.codely.shared.domain.Service;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+
+import java.lang.reflect.InvocationTargetException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+@Service
+public final class DomainEventsInformation {
+ HashMap> indexedDomainEvents;
+
+ public DomainEventsInformation() {
+ Reflections reflections = new Reflections("tv.codely");
+ Set> classes = reflections.getSubTypesOf(DomainEvent.class);
+
+ try {
+ indexedDomainEvents = formatEvents(classes);
+ } catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
+ e.printStackTrace();
+ }
+ }
+
+ public Class extends DomainEvent> forName(String name) {
+ return indexedDomainEvents.get(name);
+ }
+
+ public String forClass(Class extends DomainEvent> domainEventClass) {
+ return indexedDomainEvents.entrySet()
+ .stream()
+ .filter(entry -> Objects.equals(entry.getValue(), domainEventClass))
+ .map(Map.Entry::getKey)
+ .findFirst().orElse("");
+ }
+
+ private HashMap> formatEvents(
+ Set> domainEvents
+ ) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
+ HashMap> events = new HashMap<>();
+
+ for (Class extends DomainEvent> domainEvent : domainEvents) {
+ DomainEvent nullInstance = domainEvent.getConstructor().newInstance();
+
+ events.put((String) domainEvent.getMethod("eventName").invoke(nullInstance), domainEvent);
+ }
+
+ return events;
+ }
+}
diff --git a/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/mysql/MySqlDomainEventsConsumer.java b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/mysql/MySqlDomainEventsConsumer.java
new file mode 100644
index 0000000..7b043ba
--- /dev/null
+++ b/04-eventual_consistency/2-ensure_event_is_always_published/src/shared/main/tv/codely/shared/infrastructure/bus/event/mysql/MySqlDomainEventsConsumer.java
@@ -0,0 +1,96 @@
+package tv.codely.shared.infrastructure.bus.event.mysql;
+
+import jakarta.transaction.Transactional;
+import org.hibernate.SessionFactory;
+import org.hibernate.query.NativeQuery;
+import org.springframework.beans.factory.annotation.Qualifier;
+import tv.codely.shared.domain.Utils;
+import tv.codely.shared.domain.bus.event.DomainEvent;
+import tv.codely.shared.domain.bus.event.EventBus;
+import tv.codely.shared.infrastructure.bus.event.DomainEventsInformation;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.sql.Timestamp;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+public class MySqlDomainEventsConsumer {
+ private final SessionFactory sessionFactory;
+ private final DomainEventsInformation domainEventsInformation;
+ private final EventBus bus;
+ private final Integer CHUNKS = 200;
+ private Boolean shouldStop = false;
+
+ public MySqlDomainEventsConsumer(
+ @Qualifier("mooc-session_factory") SessionFactory sessionFactory,
+ DomainEventsInformation domainEventsInformation,
+ EventBus bus
+ ) {
+ this.sessionFactory = sessionFactory;
+ this.domainEventsInformation = domainEventsInformation;
+ this.bus = bus;
+ }
+
+ @Transactional
+ public void consume() {
+ while (!shouldStop) {
+ NativeQuery query = sessionFactory.getCurrentSession().createNativeQuery(
+ "SELECT * FROM domain_events ORDER BY occurred_on ASC LIMIT :chunk"
+ );
+
+ query.setParameter("chunk", CHUNKS);
+
+ List