ES | My Movies checklist - Front & Backend con DDD y CQRS

http://localhost:4000/2022/12/01/movies-checklist-1/basic-pages.png

La idea de este post es hacer un proyecto completo desde cero. Este es el primero de 24 apartados y espero que este proyecto te llame la atención, cualquier comentario no dudes en contactarme @whistler092

Descripcion general y frontend

Disclamer: No soy un experto en react ni mucho menos. La idea es encapsular mi proceso de creación de un proyecto para los amigos que leen este post :D

El proyecto se llama My Movies Checklist y consiste en un software donde se puedan ver todas las películas del mercado, poder llevar un seguimiento de las películas que ya te vistes y las que te quieres ver.

También poder compartir tus opiniones con el resto de mundo y tener tu propio ranking de películas personales.

La idea es tener 3 paginas básicas:

  1. Películas: En esta página estarán todas las películas del cine, cada tarjeta tendrá el poster, el nombre de la película, genero, fecha y una corta sinopsis. En esa carta habrán 2 botones, el primero es para marcar que la película ya te la viste, te pedirá un puntaje y agregar unos comentarios. En la segunda es para agregar esa película en una lista de películas por ver.

  2. Comentarios: En esta página se verán todos los comentarios de todos los usuarios a las películas que se han visto.

  3. Tu perfil: En esta página se pueden ver todas las películas que te has visto y las películas que están pendientes por ver.

La integración se hará con el web service themoviedb.org.

Repositorio del projecto

Vamos a ir saltando del back-end al front-end dependiendo de lo que necesitemos, así que vamos a iniciar con el proyecto react:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

~/workspace|⇒ mkdir movies
~/workspace|⇒ cd movies
~/workspace/movies|⇒ git init
Initialized empty Git repository in /Users/ramiroandres/workspace/movies/.git/
~/workspace/movies|master ⇒ npm create vite@latest movies-frontend -- --template react

Need to install the following packages:
create-vite@3.2.1
Ok to proceed? (y) Y

Scaffolding project in /Users/ramiroandres/workspace/movies/movies-frontend...

Done. Now run:

cd movies-frontend
npm install
npm run dev

~/workspace/movies|master⚡ ⇒ cd movies-frontend
~/workspace/movies/movies-frontend|master⚡ ⇒ npm install
added 86 packages, and audited 87 packages in 11s

8 packages are looking for funding
run `npm fund` for details

found 0 vulnerabilities
~/workspace/movies/movies-frontend|master⚡ ⇒ npm run dev

Ya tenemos nuestro proyecto react creado, así que agreguemos el enrutador con react router. Aquí un tutorial de la página oficial para aprender unos buenos fundamentos https://reactrouter.com/en/main/start/tutorial

1
2
3

~/workspace/movies/movies-frontend|master⚡ ⇒ npm install react-router-dom localforage match-sorter sort-by

Yo aprendí react antes de React Hooks pero no he tenido la oportunidad de seguir trabajando en él; para mi react ha cambiado un montón, ando en proceso de reaprender y me disculparan si cometo algún error. El enrutador de react también ha cambiado mucho así que básicamente sentí que aprendí nuevamente react con esto. ¡Así que vamos a darle!

Las primeras rutas que necesitamos aquí son las de:

  • Peliculas: /movies
  • Comentarios: /comments
  • Mi perfil: /profile

Vamos a crearlas:

Creamos un nuevo folder llamado routes donde ubicaremos todas las páginas. Por lo pronto nuestro main.jsx va a lucir asi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import React from "react";
import ReactDOM from "react-dom/client";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import App from "./App";
import "./index.css";
import Comments from "./routes/comments";
import Movies from "./routes/movies";
import Profile from "./routes/profile";
import Root from "./routes/root";

const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{
path: "movies",
element: <Movies />,
},
{
path: "comments",
element: <Comments />,
},
{
path: "profile",
element: <Profile />,
},
],
},
]);

ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

Hemos cambiado el objeto <App /> que se retorna en el createRoot y ahora vamos a retornar un objeto del react-router-dom. El <RouterProvider> nos permite que todos los objetos que se le pasen, van a estar bajo el dominio del enrutador. Por tal razón, vamos a pasarle el objeto router definido arriba.

En este objeto hay que crear 4 nuevas rutas, el root / que contendrá todo el menú. En este componente <Root /> que por el momento va a ser algo dummy tendrá el objeto <Outlet /> donde se hará render de los children definidos anteriormente.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Outlet } from "react-router";

export default function Root() {
return (
<>
<div id="header">
<h1>React Movies</h1>
</div>
<div id="detail">
<Outlet />
</div>
</>
);
}

Para los children, vamos a tener 3 rutas, el /movies, /comments y /profile.

Vamos a crear estos componentes también vacíos con tal de tener un acercamiento básico de las páginas que tendremos.

1
2
3
4
5
6
7
export default function Profile() {
return (
<>
<h2>My Profile</h2>
</>
);
}

Hasta el momento, hemos creado el siguiente workflow

Aquí podemos chequear el código en mi repositorio

Commit de creacion de rutas

En el próximo post vamos a armar un menú básico para poder navegar por las diferentes rutas y vamos a cargar una información dummy para poder interactuar.

Enrutamiento, info dummy y css

En el apartado anterior, agregamos el enrutamiento de react con las paginas que necesitamos: /Movies, /Comments y /Profile. La meta de este post es armar un menú básico para poder navegar por las diferentes rutas y vamos a cargar una información dummy para poder interactuar.

Hay varias formas de armar un menú, una de ellas es con un framework frontend como React Bootstrap y tiene un menú bastante interesante, si les interesa, échenle un ojo. En este caso, vamos a intentar evitar agregar Bootstrap y hacerlo manual para practicar un poco.

Así que … primero que todo, quiero presentarles Sass: Syntactically Awesome Style Sheets, Sass es un preprocesador de CSS que nos ayudara a darle vitaminas al CSS. Es muy fácil de usar, porque la sintaxis es la misma que la de CSS, solo que permite anidación. Ya vamos a verlo!

Para instalarlo, vamos a ir a la consola y escribir npm install sass y ya. React automáticamente detectara los archivos con extensión .scss.

Uno de los cambios que vamos a iniciar es modificar el root.jsx para agregar nuestro nuevo menú.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Outlet } from "react-router";
import { NavLink } from "react-router-dom";
import "./root.scss";

export default function Root() {
return (
<>
<div id="header" className="header">
<ul>
<li key="Movies">
<NavLink to="Movies">Movies</NavLink>
</li>
<li key="Comments">
<NavLink to="Comments">Comments</NavLink>
</li>
<li key="Profile">
<NavLink to="Profile">Profile</NavLink>
</li>
<div className="dot"></div>
</ul>
</div>
<div id="detail">
<Outlet />
{/* other elements */}

Aquí vamos a hacer uso del NavLink que nos permitirá hacer un redireccionamiento con el react-router-dom.

Creemos un nuevo archivo llamado /src/routes/root.scss. Importado en el archivo anterior, vamos a agregarle un poquito de estilo a este menú:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#header {
margin-top: 2rem;
margin-bottom: 2rem;

ul {
display: flex;

li {
flex: 1;
overflow: auto;
padding-top: 1rem;
}

li a {
color: #f6f4e6;
/* border: 1px solid white; */
text-decoration: none;
font-size: 1.2em;
font-weight: 500;
display: inline-block;

-webkit-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
}

li a.active {
color: #fddb3a;
}

li a:hover {
color: #fddb3a;
}
}
}

Una de las cosas mas interesantes de usar scss es los componentes anidados. Como pueden ver, dentro del id header, todos los componentes ul serán afectados con el CSS adentro.

Otra de las cosas que me gusta usar es flexbox. Les recomiendo un tutorial bastante interesante de los magos de css-tricks.com. El valor por defecto de flex es organizar los componentes de forma horizontal, lo cual es lo que necesitamos. Adicionalmente, ajuste unos colores del src/index.css, importé un font llamado Montserrat y ajuste el body con flexbox también.

La forma que uso para importar un nuevo font es usando la etiqueta import. Si quieres mirar más fonts, chequea esta página

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap");

:root {
font-family: "Montserrat", sans-serif;
/* other elements */
}
/* other elements */
body {
margin: 0;

min-width: 320px;
min-height: 100vh;

display: flex;
flex-direction: column;
align-content: space-between;
justify-content: flex-start;
align-items: center;
text-align: center;
}

Aquí un commit hasta este punto

Ahora lo que vamos a hacer es mostrar un par de películas de forma fake antes de crear el backend y registrarnos en themoviedb.org, todo esto para saber exactamente que propiedades necesitamos mostrar en la UI. Así que descarguemos una json previamente guardad de del API de películas populares de la siguiente URL y guardemos ese json en el path /public/db.json.

Hay varias formas de hacer esto, vamos a hacer una petición HTTP para obtener este archivo de nuestra carpeta pública, y así para poder reutilizar parte del código cuando llegue muestro backend, vamos a instalar axios usando npm install axios.

Abramos nustro archivo src/routes/movies.jsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import axios from "axios";
import { useEffect, useState } from "react";

export default function Movies() {
const [movies, setMovies] = useState([]);

useEffect(() => {
axios
.get("db.json")
.then((res) => setMovies(res.data))
.catch((err) => console.error(err));
}, []); // To run only once

console.log("movies", movies);

return (
<>
<h2>Movies</h2>
</>
);
}

Vamos a usar react hooks, esto nos permite usar estados y otras características de react sin tener que escribir clases. useState nos ayuda para mantener estados dentro de la función. Aquí puedes leer documentación al respecto si no estas familiarizado.

Si tuviéramos que escribir el anterior código de useState con clases, sería algo como lo siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Movies extends React.Component {
constructor(props) {
super(props);
this.state = {
movies: [],
};
}

render() {
return (
<>
<h2>Movies</h2>
</>
);
}
}

También vamos a usar useEffect, esto nos permite realizar efectos secundarios en un componente. Podemos realizar cosas como obtener data de un API, subscribirse o manualmente cambiar el DOM. En nuestro caso, vamos a usarlo para invocar axios y traer el json del path público, finalmente en el .then, vamos a guardar la información en el state. Así es como podremos obtener el resultado siguiente resultado en consola:

1
2
movies []
movies {page: 1, results: Array(20), total_pages: 36103, total_results: 722056}

Ahora hagamos render de los títulos:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function Movies() {
/* other elements */

return (
<>
<div className="films">
{movies &&
movies?.results?.map((film) => <Film key={film.id} film={film} />)}
</div>
</>
);
}

function Film({ film }) {
return (
<div key={film.id} className="film">
<img
src={`https://image.tmdb.org/t/p/w185_and_h278_bestv2${film.poster_path}`}
></img>
<span>{film.original_title}</span>
</div>
);
}

Vamos a agregar un poco de estilo a esta página, por tal razón, vamos a crear un nuevo archivo src/routes/movies.scss y organicemos un poco las películas:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.films {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
align-items: center;

.film {
color: white;
min-width: 200px;
max-width: 300px;

display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;

margin: 1rem 1rem 2rem 1rem;
inline-size: 150px;
overflow-wrap: break-word;

min-height: 340px;
}
}

Vamos a organizar todas las películas en la clase films y usaremos flexbox con flex-direction: row; para acomodar todas las películas de forma horizontal y complementamos con flex-wrap: wrap para que salten de línea cuando se llene cada row.

Dentro de cada película, vamos a ajustar un poco cada uno, dándole un tamaño fijo mínimo y máximo. Tambien usaremos flexbox para que todo sea de tipo columna.

Ahora las películas podrían lucir de la siguiente forma:

Aquí un commit con los últimos cambios

Diagrama de base de datos

En el apartado anterior, mejoramos un poco la ruta de movies mostrando las últimas películas desde una data moq, la idea de este post es crear nuestro backend usando .net Core, EF y PostgreSQL.

Antes de arrancar a hacer un diagrama de base de datos, necesitamos validar algunos requerimientos.

  • Como usuario, quiero poder buscar cualquier película dentro del catálogo, cada película debe tener un identificador único. Se debe persistir la película en la base de datos con información básica.
  • Como usuario, quiero poder seleccionar una película y poder marcar cualquier película para ver.
  • Como usuario, quiero poder seleccionar una película y poder marcarla como vista. También poder agregar un comentario y una puntuación a las películas que el usuario haya visto.
  • Quiero poder listar mis películas pendientes a verme y las películas vistas.

Ahora sí, con esto podríamos definir la estructura de la base de datos. He creado un diagrama usando dbdiagram.io para visualizar las relaciones:

La idea es crear un usuario users, que es un usuario registrado desde el proveedor de autenticación de Auth0. Un usuario podrá tener varias películas asociadas users_movies y hay dos tipos de asociaciones: Las películas por ver to_watch y las películas vistas saw_it. Cuando se asocia una película vista, este puede tener uno o más comentarios comments.

Vamos a crear las tablas en la base de datos.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

CREATE TABLE users (
user_id serial PRIMARY KEY,
email VARCHAR ( 255 ) UNIQUE NOT NULL,
auth0_id VARCHAR ( 30 ) UNIQUE NOT NULL,
created_on TIMESTAMP NOT NULL,
last_login TIMESTAMP
);

CREATE TABLE movies (
movie_id BIGSERIAL PRIMARY KEY,
original_title VARCHAR ( 255 ) NOT NULL,
title VARCHAR ( 255 ) NOT NULL,
overview VARCHAR ( 1024 ) NOT NULL,
themoviedb_id INT UNIQUE NOT NULL,
genre VARCHAR ( 255 ) NOT NULL,
created_on TIMESTAMP NOT NULL,
last_update TIMESTAMP
);

CREATE TABLE users_movies (
user_movie_id BIGSERIAL PRIMARY KEY,
movie_id bigint not null,
user_id bigint not null,

to_watch boolean not null default false,
saw_it boolean not null default false,

created_on TIMESTAMP NOT NULL,
last_update TIMESTAMP,

FOREIGN KEY (movie_id) REFERENCES movies(movie_id),
FOREIGN KEY (user_id) REFERENCES users(user_id)
);

CREATE TABLE comments (
comment_id BIGSERIAL PRIMARY KEY,
user_movie_id bigint not null,

comment varchar(200) default null,
score decimal(2,1) default null,
liked smallint default 0::smallint,

created_on TIMESTAMP NOT NULL,
last_update TIMESTAMP,

FOREIGN KEY (user_movie_id) REFERENCES users_movies(user_movie_id)
);


Haciendo la prueba de concepto, podemos tener los siguientes query’s. Arranquemos por llenar información básica

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

-- Add new user
INSERT INTO public.users(
user_id, email, auth0_id, created_on, last_login)
VALUES (1, 'iamramiroo@gmail.com', '1', now(), now());

select * from public.users;

1 "iamramiroo@gmail.com" "1" "2022-12-08 12:47:42.539151" "2022-12-08 12:47:42.539151"

-- add new movie
INSERT INTO public.movies(
movie_id, original_title, title, overview, themoviedb_id, genre, created_on, last_update)
VALUES (1,
'Black Panther: Wakanda Forever',
'Black Panther: Wakanda Forever',
'Queen Ramonda, Shuri, M’Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T’Challa’s death.',
505642,
'Action, Adventure, Science Fiction',
now(),
now());

select movie_id,
original_title,
title,
overview,
themoviedb_id,
genre,
created_on,
last_update
from movies;

1 "Black Panther: Wakanda Forever" "Black Panther: Wakanda Forever" "Queen Ramonda, Shuri, M’Baku, Okoye and the Dora Milaje fight to protect their nation from intervening world powers in the wake of King T’Challa’s death." 505642 "Action, Adventure, Science Fiction" "2022-12-08 12:55:48.957749" "2022-12-08 12:55:48.957749"

Ahora asociemos el usuario con una película de modo para ver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

INSERT INTO public.users_movies(
user_movie_id, movie_id, user_id, to_watch, saw_it, created_on, last_update)
VALUES (1, 1, 1, true, false, now(), now());

select * from users_movies

1 1 1 true false "2022-12-08 12:59:57.516897" "2022-12-08 12:59:57.516897"

-- All Movies to watch
select u.email,
m.title,
um.saw_it,
um.to_watch
from users_movies um
inner join movies m on m.movie_id = um.movie_id
inner join users u on u.user_id = um.user_id
where um.user_id = 1
and um.to_watch = true

"iamramiroo@gmail.com" "Black Panther: Wakanda Forever" false true

Ahora asociemos un comentario con una asociación de una película

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
-- Add new comment into a movie
INSERT INTO public.comments(
comment_id, user_movie_id, comment, score, liked, created_on, last_update)
VALUES (1, 1, 'New comment test', 4.5, 0, now(), now());

select * from public.comments;

1 1 "New comment test" 4.5 0 "2022-12-08 13:05:38.794491" "2022-12-08 13:05:38.794491"


-- All movies with comments by user
select u.email,
m.title,
um.saw_it,
um.to_watch,
c.score,
c.liked,
c.comment
from users_movies um
inner join movies m on m.movie_id = um.movie_id
inner join users u on u.user_id = um.user_id
left join comments c on um.user_movie_id = c.user_movie_id
where um.user_id = 1

"iamramiroo@gmail.com" "Black Panther: Wakanda Forever" false true 4.5 0 "New comment test"

-- Average Score

select
um.movie_id,
m.original_title,
trunc(avg(c.score), 1) as score
from users_movies um
inner join movies m on um.movie_id = m.movie_id
inner join comments c on um.user_movie_id = c.user_movie_id
group by um.movie_id, m.original_title

1 "Black Panther: Wakanda Forever" 4.5

En caso de que sea necesario, un script para borrar todo

1
2
3
4
5
6
7
8

-- Reset all

drop table comments;
drop table users_movies;
drop table movies;
drop table users;

DDD y creacion del proyecto backend

En el apartado anterior, estuvimos creando lo que seria una idea inicial de la base de datos, ahora vamos a iniciar creando nuestra API. Este post estoy tratando de plasmar mi opinión personal en este momento, la cual puede cambiar 😅😅.

Nota 2: Puede que algunos conceptos sean avanzados para el proyecto que intento hacer, pero intentare explicarlos lo mejor que pueda, así que si sientes que algo no sea suficientemente claro, no dudes en escribirme y podemos mejorar el post. Gracias :D

El acercamiento que vamos a estar usando en este proyecto es el de Clean Architecture y Domain Driven Design. Vamos a iniciar dando una breve explicación de que es Domain-Driven Design aka DDD y porque me parece importante integrarlo en mis proyectos. ¿Entonces, en que consiste DDD y Arquitectura Limpia? Bueno, esto es una serie de principios y prácticas que nos permitirán modelar nuestro software para ser mantenible y mucho más escalables. Conceptos como Arquitectura Limpia, Arquitectura Cebolla o Arquitectura Hexagonal comparten la misma meta: crear sistemas robustos y desacoplados. Veamos un diagrama de cómo podríamos estructurar nuestra aplicación:

Con este acercamiento, (Como lo describe Jason Taylor en este gran video) el Domain y el Application son las unidades centrales de la aplicación, esto es conocido como el Core del sistema.

  • Domain: Contiene la lógica de negocio universal (entidades, objetos de valor, lógica pura que no cambia entre aplicaciones). No depende de nada.
  • Application: Contiene la lógica específica de la aplicación (casos de uso, comandos, queries). Depende solo del Dominio.

Las capas externas, Presentation (la API) e Infrastructure (la base de datos, servicios externos), dependen del Core, pero no al revés. Esta inversión de dependencias se logra usando interfaces en la capa de Application, que son implementadas en la capa de Infrastructure. Así, si mañana queremos cambiar de PostgreSQL a SQL Server, solo cambiamos la implementación en Infrastructure sin tocar la lógica de negocio.

Algunas características de Clean Architecture:

  • Independiente del Framework o lenguaje de programación.
  • Testeable: La lógica de negocio no conoce la base de datos ni la UI, por lo que es fácil de probar.
  • Independiente de la UI y de la base de datos.
  • Independiente de factores externos.

Yendo un poquito más al detalle de los tipos de cosas que vamos a ver en cada capa son:

  • Domain: Entidades, Objetos de Valor, Lógica de negocio pura.
  • Application: Interfaces (ej. IMovieRepository), Lógica de aplicación, Comandos/Queries, Validaciones.
  • Infrastructure: Implementación de persistencia (Entity Framework), Identidad (Auth0), envío de emails, etc.
  • Presentation: Controladores de Web API, páginas MVC, o en nuestro caso, el frontend en React que consumirá esta API.

Listo, entonces vamos a iniciar creando el proyecto desde cero. Para ello tenemos que tener el SDK de .Net Core instalado.

Para iniciar, abrimos la terminal y ejecutamos los siguientes comandos para crear la solución:

1
2
3
4
PS C:\...> cd .\movies-api\

PS C:\...\movies-api> dotnet new sln -o Movies
PS C:\...\movies-api> cd .\Movies\

Vamos a crear la capa de presentación (Movies.Api y Movies.Contracts) y las capas del Core e Infraestructura:

1
2
3
4
5
6
7
8
9
10
11
12
13
# Presentation
PS C:\...\Movies> dotnet new webapi -o Movies.Api
PS C:\...\Movies> dotnet new classlib -o Movies.Contracts

# Core
PS C:\...\Movies> dotnet new classlib -o Movies.Application
PS C:\...\Movies> dotnet new classlib -o Movies.Domain

# Infrastructure
PS C:\...\Movies> dotnet new classlib -o Movies.Infrastructure

# Add all projects to the solution
PS C:\...\Movies> dotnet sln add (ls -r **\*.csproj)

Ya que tenemos todos los proyectos creados, agreguemos las dependencias entre ellos de acuerdo al siguiente diagrama:

1
2
3
4
5
6
7
8
9
10
11
12
# Presentation depends on Application and Contracts
PS C:\...\Movies> dotnet add .\Movies.Api\ reference .\Movies.Application\
PS C:\...\Movies> dotnet add .\Movies.Api\ reference .\Movies.Contracts\

# Infrastructure depends on Application
PS C:\...\Movies> dotnet add .\Movies.Infrastructure\ reference .\Movies.Application\

# Application depends on Domain
PS C:\...\Movies> dotnet add .\Movies.Application\ reference .\Movies.Domain\

# Technical dependency for DI registration
PS C:\...\Movies> dotnet add .\Movies.Api\ reference .\Movies.Infrastructure\

NOTA: Aunque conceptualmente Presentation no debería depender de Infrastructure, agregamos la referencia para que el contenedor de Inyección de Dependencias en Movies.Api pueda registrar los servicios definidos en Infrastructure. No usaremos clases de Infrastructure directamente en la API.

1
PS C:\...\Movies> dotnet build

Corremos el backend para verificar que todo funciona:

1
PS C:\...\Movies> dotnet run --project .\Movies.Api\

Entidades y Configuración de CQRS

En el apartado anterior, creamos la estructura de la solución .NET. Ahora, vamos a definir nuestras entidades de dominio y a configurar la aplicación para usar el patrón CQRS con MediatR.

Primero, limpiamos el código de ejemplo de la plantilla:

1
2
PS C:\...\Movies> rm .\Movies.Api\Controllers\WeatherForecastController.cs
PS C:\...\Movies> rm .\Movies.Api\WeatherForecast.cs

Creación de Entidades de Dominio (Enfoque DDD)

En el proyecto Movies.Domain, crearemos clases que representen nuestras tablas. Pero en lugar de crear simples contenedores de datos (POCOs), aplicaremos principios de DDD para crear un modelo de dominio rico y robusto.

1. Entidad Base

Primero, una pequeña abstracción. Todas nuestras entidades tendrán un Id. Podemos crear una clase base para manejar esto y la comparación de igualdad, que en DDD se basa en la identidad.

Movies.Domain/Common/Entity.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace Movies.Domain.Common;

public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; protected set; }

protected Entity(TId id)
{
Id = id;
}

// Sobrescribimos la igualdad para que dos entidades sean iguales si sus Ids son iguales.
public override bool Equals(object? obj)
{
return obj is Entity<TId> entity && Id.Equals(entity.Id);
}

public override int GetHashCode()
{
return Id.GetHashCode();
}
}

2. Entidad Movie

La entidad Movie representa una película de nuestro catálogo. Su información proviene principalmente de una fuente externa (TheMovieDB), por lo que su comportamiento es limitado, pero su creación debe ser controlada.

Movies.Domain/Entities/Movie.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
using Movies.Domain.Common;

namespace Movies.Domain.Entities;

public class Movie : Entity<long>
{
public string OriginalTitle { get; private set; }
public string Title { get; private set; }
public string Overview { get; private set; }
public int TheMovieDbId { get; private set; }
public string Genre { get; private set; }
public DateTime CreatedOn { get; private set; }
public DateTime? LastUpdate { get; private set; }

// Constructor privado para que solo pueda ser llamado por EF Core o por nuestro método de fábrica.
private Movie(long movieId, string originalTitle, string title, string overview, int theMovieDbId, string genre)
: base(movieId)
{
OriginalTitle = originalTitle;
Title = title;
Overview = overview;
TheMovieDbId = theMovieDbId;
Genre = genre;
}

// Método de fábrica (Factory Method): La única forma "pública" de crear una Película.
// Esto garantiza que ninguna película se pueda crear en un estado inválido.
public static Movie Create(string originalTitle, string title, string overview, int theMovieDbId, string genre)
{
// Aquí irían las validaciones (invariantes)
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("El título no puede estar vacío.", nameof(title));
if (theMovieDbId <= 0)
throw new ArgumentException("El Id de TheMovieDB debe ser válido.", nameof(theMovieDbId));

var movie = new Movie(0, originalTitle, title, overview, theMovieDbId, genre)
{
CreatedOn = DateTime.UtcNow
};

return movie;
}

// Necesario para EF Core, que necesita un constructor sin parámetros.
#pragma warning disable CS8618
private Movie() : base(0) { }
#pragma warning restore CS8618
}

¿Por qué este enfoque?

  • Encapsulación: Los setters son privados. El estado de la entidad no puede ser modificado arbitrariamente desde el exterior.
  • Invariantes: El método Create se asegura de que una película siempre se cree con los datos necesarios (un título, un ID de TMDB válido, etc.). Protege la integridad del objeto.

3. Entidad User (Aggregate Root)

El User es un Aggregate Root. Es la entidad principal de un grupo de objetos relacionados (el “agregado”). En nuestro caso, un User es dueño de su lista de UserMovie y sus Comment. Toda la interacción con estas entidades hijas debe pasar a través del User.

Movies.Domain/Entities/User.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
using Movies.Domain.Common;

namespace Movies.Domain.Entities;

public class User : Entity<long>
{
public string Email { get; private set; }
public string Auth0Id { get; private set; }
public DateTime CreatedOn { get; private set; }
public DateTime? LastLogin { get; private set; }

// La lista es privada para que no se pueda manipular desde afuera.
private readonly List<UserMovie> _userMovies = new();
// Exponemos una versión de solo lectura para consulta.
public IReadOnlyCollection<UserMovie> UserMovies => _userMovies.AsReadOnly();

private User(long userId, string email, string auth0Id) : base(userId)
{
Email = email;
Auth0Id = auth0Id;
}

public static User Create(string email, string auth0Id)
{
// Validaciones...
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
throw new ArgumentException("Email inválido.", nameof(email));

var user = new User(0, email, auth0Id)
{
CreatedOn = DateTime.UtcNow,
LastLogin = DateTime.UtcNow
};
return user;
}

// --- Comportamiento del Dominio ---

public void AddToWatchlist(Movie movie)
{
// Regla de negocio: no agregar si ya está en la lista (vista o por ver).
if (_userMovies.Any(um => um.MovieId == movie.Id))
{
// Podríamos lanzar una excepción o simplemente no hacer nada.
return;
}

var userMovie = UserMovie.CreateForWatchlist(this.Id, movie.Id);
_userMovies.Add(userMovie);
}

public void MarkAsSeen(Movie movie, decimal score, string commentText)
{
var userMovie = _userMovies.FirstOrDefault(um => um.MovieId == movie.Id);

if (userMovie is not null)
{
// Si ya estaba en "por ver", la marcamos como vista.
userMovie.MarkAsSeen();
}
else
{
// Si no existía, la creamos directamente como "vista".
userMovie = UserMovie.CreateAsSeen(this.Id, movie.Id);
_userMovies.Add(userMovie);
}

// Agregamos el comentario a través de la entidad UserMovie
userMovie.AddComment(score, commentText);
}

#pragma warning disable CS8618
private User() : base(0) { }
#pragma warning restore CS8618
}

4. Entidad UserMovie

Esta es una entidad hija dentro del agregado User. No tiene sentido que exista sin un usuario. Su ciclo de vida es gestionado por el User.

Movies.Domain/Entities/UserMovie.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
using Movies.Domain.Common;

namespace Movies.Domain.Entities;

public class UserMovie : Entity<long>
{
public long MovieId { get; private set; }
public long UserId { get; private set; }
public bool ToWatch { get; private set; }
public bool SawIt { get; private set; }
public DateTime CreatedOn { get; private set; }
public DateTime? LastUpdate { get; private set; }

private readonly List<Comment> _comments = new();
public IReadOnlyCollection<Comment> Comments => _comments.AsReadOnly();

// Constructor interno para que solo el Agregado (User) pueda crearlo.
internal UserMovie(long id, long userId, long movieId) : base(id)
{
UserId = userId;
MovieId = movieId;
CreatedOn = DateTime.UtcNow;
}

internal static UserMovie CreateForWatchlist(long userId, long movieId)
{
var userMovie = new UserMovie(0, userId, movieId)
{
ToWatch = true,
SawIt = false
};
return userMovie;
}

internal static UserMovie CreateAsSeen(long userId, long movieId)
{
var userMovie = new UserMovie(0, userId, movieId)
{
ToWatch = false,
SawIt = true
};
return userMovie;
}

internal void MarkAsSeen()
{
// Regla de negocio: una película vista ya no está "por ver".
if (SawIt) return; // Ya está vista, no hacer nada.

ToWatch = false;
SawIt = true;
LastUpdate = DateTime.UtcNow;
}

internal void AddComment(decimal score, string text)
{
// Regla de negocio: solo se puede comentar si la película se ha visto.
if (!SawIt)
{
throw new InvalidOperationException("No se puede comentar una película que no ha sido vista.");
}
var comment = Comment.Create(this.Id, score, text);
_comments.Add(comment);
}

#pragma warning disable CS8618
private UserMovie() : base(0) { }
#pragma warning restore CS8618
}

5. Entidad Comment

Es la última pieza de nuestro agregado. Vive dentro de un UserMovie y su creación es controlada por este.

Movies.Domain/Entities/Comment.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using Movies.Domain.Common;

namespace Movies.Domain.Entities;

public class Comment : Entity<long>
{
public long UserMovieId { get; private set; }
public string Text { get; private set; }
public decimal Score { get; private set; }
public int Liked { get; private set; }
public DateTime CreatedOn { get; private set; }
public DateTime? LastUpdate { get; private set; }

internal Comment(long id, long userMovieId, decimal score, string text) : base(id)
{
UserMovieId = userMovieId;
Score = score;
Text = text;
CreatedOn = DateTime.UtcNow;
}

internal static Comment Create(long userMovieId, decimal score, string text)
{
// Invariante: El puntaje debe estar en un rango válido.
if (score < 0 || score > 10)
{
throw new ArgumentOutOfRangeException(nameof(score), "El puntaje debe estar entre 0 y 10.");
}
if (string.IsNullOrEmpty(text) || text.Length > 200)
{
throw new ArgumentException("El comentario no puede estar vacío o exceder los 200 caracteres.", nameof(text));
}

return new Comment(0, userMovieId, score, text);
}

public void AddLike()
{
Liked++;
LastUpdate = DateTime.UtcNow;
}

#pragma warning disable CS8618
private Comment() : base(0) { }
#pragma warning restore CS8618
}

¿Qué hemos ganado con este enfoque?

  • Claridad: El código expresa claramente las reglas de negocio (ej. MarkAsSeen, AddComment).
  • Robustez: Es mucho más difícil poner el sistema en un estado inválido. No puedes crear un comentario para una película que no has visto.
  • Mantenibilidad: Si una regla de negocio cambia (ej. el puntaje ahora es de 1 a 5), el cambio se hace en un solo lugar: la entidad Comment.
  • Cohesión: La lógica que pertenece a una entidad está dentro de esa entidad.

Este es el corazón de un diseño guiado por el dominio. Ahora, la capa de Aplicación usará estas entidades para orquestar los casos de uso, sabiendo que el dominio se protegerá a sí mismo.

Configuración de CQRS con MediatR

CQRS (Command Query Responsibility Segregation) es un patrón que separa las operaciones que leen datos (Queries) de las que modifican datos (Commands). Esto simplifica cada lado.

MediatR es una librería que implementa el patrón Mediator. Nos ayuda a desacoplar el emisor de una solicitud (ej. un controlador API) de su manejador (la lógica de negocio en la capa de Aplicación). Es perfecto para implementar CQRS.

Instalemos los paquetes necesarios en el proyecto Movies.Application:

1
2
PS C:\...\Movies> dotnet add .\Movies.Application\ package MediatR
PS C:\...\Movies> dotnet add .\Movies.Application\ package MediatR.Extensions.Microsoft.DependencyInjection

Ahora, registramos MediatR en el contenedor de dependencias. Abre Program.cs en Movies.Api y agrega la siguiente línea:

Movies.Api/Program.cs

1
2
3
4
5
6
7
// ...
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddMediatR(typeof(Movies.Application.IAssemblyMarker).Assembly); // Reemplazaremos IAssemblyMarker

// ...

Para que MediatR encuentre los manejadores, necesita saber en qué ensamblado (proyecto) buscar. Creamos una interfaz vacía en Movies.Application que sirva como marcador.

Movies.Application/IAssemblyMarker.cs

1
2
3
namespace Movies.Application;

public interface IAssemblyMarker { }

Y actualizamos Program.cs:

1
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Movies.Application.IAssemblyMarker).Assembly));

Configuración de Persistencia con Entity Framework Core

Ahora, conectemos nuestra aplicación a la base de datos PostgreSQL usando EF Core, un ORM (Object-Relational Mapper) que nos permite trabajar con la base de datos usando objetos C#.

Instalemos los paquetes de EF Core en el proyecto Movies.Infrastructure:

1
2
PS C:\...\Movies> dotnet add .\Movies.Infrastructure\ package Microsoft.EntityFrameworkCore
PS C:\...\Movies> dotnet add .\Movies.Infrastructure\ package Npgsql.EntityFrameworkCore.PostgreSQL

Y la herramienta de migraciones en Movies.Api para poder ejecutar comandos dotnet ef:

1
PS C:\...\Movies> dotnet add .\Movies.Api\ package Microsoft.EntityFrameworkCore.Design

A continuación, creamos nuestro DbContext en Movies.Infrastructure. Esta clase representa una sesión con la base de datos.

Movies.Infrastructure/Persistence/MoviesDbContext.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using Microsoft.EntityFrameworkCore;
using Movies.Domain.Entities;

namespace Movies.Infrastructure.Persistence;

public class MoviesDbContext : DbContext
{
public MoviesDbContext(DbContextOptions<MoviesDbContext> options) : base(options) { }

public DbSet<Movie> Movies { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;
public DbSet<UserMovie> UserMovies { get; set; } = null!;
public DbSet<Comment> Comments { get; set; } = null!;

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MoviesDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}

Finalmente, registramos el DbContext en Program.cs y le decimos que use PostgreSQL, obteniendo la cadena de conexión desde la configuración (appsettings.json).

Movies.Api/Program.cs

1
2
3
4
5
6
7
8
9
10
// ...
using Microsoft.EntityFrameworkCore;
using Movies.Infrastructure.Persistence;

// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<MoviesDbContext>(options =>
options.UseNpgsql(connectionString));

// ...

Movies.Api/appsettings.json

1
2
3
4
5
6
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=movies_db;Username=postgres;Password=mysecretpassword"
}
// ...
}

¡Asegúrate de cambiar los datos de tu conexión!

Implementación de un Flujo End-to-End (Query)

Con todo configurado, vamos a implementar nuestro primer caso de uso: obtener todas las películas.

  1. Definir la Interfaz del Repositorio (Application): La aplicación define qué necesita, no cómo se obtiene.

    Movies.Application/Interfaces/IMovieRepository.cs

    1
    2
    3
    4
    5
    6
    7
    using Movies.Domain.Entities;

    namespace Movies.Application.Interfaces;
    public interface IMovieRepository
    {
    Task<IEnumerable<Movie>> GetAllAsync(CancellationToken cancellationToken);
    }
  2. Implementar el Repositorio (Infrastructure): La infraestructura provee la implementación concreta.

    Movies.Infrastructure/Repositories/MovieRepository.cs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    using Microsoft.EntityFrameworkCore;
    using Movies.Application.Interfaces;
    using Movies.Domain.Entities;
    using Movies.Infrastructure.Persistence;

    namespace Movies.Infrastructure.Repositories;

    public class MovieRepository : IMovieRepository
    {
    private readonly MoviesDbContext _context;

    public MovieRepository(MoviesDbContext context)
    {
    _context = context;
    }

    public async Task<IEnumerable<Movie>> GetAllAsync(CancellationToken cancellationToken)
    {
    return await _context.Movies.AsNoTracking().ToListAsync(cancellationToken);
    }
    }

    Y lo registramos en Program.cs:

    1
    2
    3
    // ...
    builder.Services.AddScoped<IMovieRepository, MovieRepository>();
    // ...
  3. Definir el Query y su Handler (Application):
    Movies.Application/Features/Movies/Queries/GetMoviesQuery.cs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    using MediatR;
    using Movies.Application.Interfaces;
    using Movies.Domain.Entities;

    namespace Movies.Application.Features.Movies.Queries;

    // El Query: no lleva datos, solo pide la lista.
    public record GetMoviesQuery() : IRequest<IEnumerable<Movie>>;

    // El Handler: contiene la lógica para resolver el query.
    public class GetMoviesQueryHandler : IRequestHandler<GetMoviesQuery, IEnumerable<Movie>>
    {
    private readonly IMovieRepository _movieRepository;

    public GetMoviesQueryHandler(IMovieRepository movieRepository)
    {
    _movieRepository = movieRepository;
    }

    public async Task<IEnumerable<Movie>> Handle(GetMoviesQuery request, CancellationToken cancellationToken)
    {
    return await _movieRepository.GetAllAsync(cancellationToken);
    }
    }
  4. Crear el Endpoint en la API (Presentation):
    Movies.Api/Controllers/MoviesController.cs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    using MediatR;
    using Microsoft.AspNetCore.Mvc;
    using Movies.Application.Features.Movies.Queries;

    [ApiController]
    [Route("api/[controller]")]
    public class MoviesController : ControllerBase
    {
    private readonly ISender _mediator;

    public MoviesController(ISender mediator)
    {
    _mediator = mediator;
    }

    [HttpGet]
    public async Task<IActionResult> GetMovies(CancellationToken cancellationToken)
    {
    var movies = await _mediator.Send(new GetMoviesQuery(), cancellationToken);
    return Ok(movies);
    }
    }

    El controlador es muy simple. Su única responsabilidad es recibir la petición HTTP, enviarla a MediatR y devolver el resultado. Toda la lógica de negocio está encapsulada y desacoplada.

Conclusión y Próximos Pasos

Si has llegado hasta aqui, hemos construido una base sólida para nuestro backend usando principios de Clean Architecture, DDD y CQRS.

En este post logramos:

  • Diseñar la base de datos y la arquitectura de la solución.
  • Configurar un proyecto .NET 7 siguiendo las mejores prácticas.
  • Implementar el patrón CQRS con MediatR para un flujo de datos limpio.
  • Conectar la aplicación a una base de datos PostgreSQL con EF Core.
  • Crear un flujo completo de extremo a extremo para consultar datos.

El proyecto está lejos de terminar, pero ahora tenemos una estructura escalable y mantenible. En los próximos posts, podríamos explorar:

  • Implementar Commands: Crear, actualizar y marcar películas como vistas o por ver.
  • Validación: Agregar validaciones a nuestros comandos usando librerías como FluentValidation.
  • Autenticación y Autorización: Integrar Auth0 para proteger nuestros endpoints.
  • Conectar el Frontend: Finalmente, hacer que nuestra aplicación React consuma esta nueva API.

Espero que este recorrido te haya sido útil.