diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 46f3645..fe33874 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -29,6 +29,9 @@ jobs: - name: Run tests run: lein test + + - name: Run architectural fitness checks + run: ./bin/check-architecture.sh - name: Build uberjar run: lein uberjar diff --git a/README.md b/README.md index 73c9df8..4182d38 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Walue - Portfolio Evaluation Service +[![CI/CD Pipeline](https://github.com/yourusername/walue/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/yourusername/walue/actions/workflows/ci-cd.yml) +[![Architectural Fitness](https://img.shields.io/badge/Architectural%20Fitness-Checked-brightgreen.svg)](https://github.com/yourusername/walue/blob/main/src/walue/infra/fitness.clj) + A Clojure web service that implements a portfolio evaluation system using Domain-Driven Design (DDD) and Hexagonal Architecture. ## Overview @@ -42,6 +45,33 @@ The project follows Hexagonal Architecture (also known as Ports and Adapters) an - **Adapters**: Implement the interfaces defined by the ports, connecting the domain to external systems (HTTP, databases, etc.). - **Infrastructure**: Provides technical capabilities like logging and metrics. +### Architectural Fitness Functions + +This project includes automated architectural fitness functions that enforce the following rules: + +1. **Layer Dependency Rules**: + - Domain can only depend on Clojure stdlib + - Port can only depend on Domain + - Adapter can depend on Domain and Port + - Infrastructure should be self-contained + - Core can depend on all layers + +2. **Domain Purity**: Ensures domain code has no external dependencies beyond Clojure stdlib + +3. **Circular Dependency Prevention**: Detects and prevents circular dependencies between components + +4. **Interface Isolation**: Ensures all ports define protocols (interfaces) + +5. **Adapter Implementation**: Verifies that adapters implement at least one port + +These checks run automatically in the CI/CD pipeline and locally via: + +```bash +./bin/check-architecture.sh +``` + +The fitness functions prevent architectural degradation over time as new changes are introduced. + ## API ### Evaluate Portfolio diff --git a/bin/check-architecture.sh b/bin/check-architecture.sh new file mode 100755 index 0000000..89e7030 --- /dev/null +++ b/bin/check-architecture.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -e + +echo "Running architectural fitness checks..." +lein run -m walue.infra.fitness \ No newline at end of file diff --git a/project.clj b/project.clj index afe2a13..b38215e 100644 --- a/project.clj +++ b/project.clj @@ -2,6 +2,7 @@ :description "Portfolio evaluation service using Domain-Driven Design and Hexagonal Architecture" :dependencies [[org.clojure/clojure "1.11.1"] [org.clojure/data.json "2.4.0"] + [org.clojure/tools.namespace "1.4.4"] [ring/ring-core "1.9.5"] [ring/ring-jetty-adapter "1.9.5"] [ring/ring-json "0.5.1"]] diff --git a/src/walue/adapter/http_adapter.clj b/src/walue/adapter/http_adapter.clj index d2fa1c8..c3ed11b 100644 --- a/src/walue/adapter/http_adapter.clj +++ b/src/walue/adapter/http_adapter.clj @@ -2,22 +2,22 @@ (:require [clojure.data.json :as json] [ring.middleware.params :refer [wrap-params]] [ring.middleware.json :refer [wrap-json-body wrap-json-response]] - [walue.infra.logging :as logging] - [walue.port.evaluation-port :as port])) + [walue.port.evaluation-port :as evaluation-port] + [walue.port.logging-port :as logging-port])) -(defn- handle-evaluate-portfolio [request evaluation-service] +(defn- handle-evaluate-portfolio [request evaluation-service logging-service] (try (let [body (:body request) portfolio (or (get body "portfolio") (get body :portfolio)) criteria (or (get body "criterios") (get body :criterios))] (if (and portfolio criteria) - (let [result (port/evaluate-portfolio evaluation-service portfolio criteria)] + (let [result (evaluation-port/evaluate-portfolio evaluation-service portfolio criteria)] {:status 200 :body result}) {:status 400 :body {:error "Invalid request. Portfolio and criteria are required."}})) (catch Exception e - (logging/error "Error evaluating portfolio:" (.getMessage e)) + (logging-port/log-error logging-service (str "Error evaluating portfolio: " (.getMessage e))) {:status 500 :body {:error "An error occurred while processing your request."}}))) @@ -25,14 +25,14 @@ {:status 200 :body {:status "UP"}}) -(defn make-handler [evaluation-service] +(defn make-handler [evaluation-service logging-service] (fn [request] (let [uri (:uri request) method (:request-method request)] - (logging/info "Request received:" method uri) + (logging-port/log-info logging-service (str "Request received: " method " " uri)) (cond (and (= uri "/api/evaluate") (= method :post)) - (handle-evaluate-portfolio request evaluation-service) + (handle-evaluate-portfolio request evaluation-service logging-service) (and (= uri "/health") (= method :get)) (handle-health-check request) @@ -41,27 +41,28 @@ {:status 404 :body {:error "Not found"}})))) -(defn wrap-logging [handler] +(defn wrap-logging [handler logging-service] (fn [request] (let [start (System/currentTimeMillis) response (handler request) duration (- (System/currentTimeMillis) start)] - (logging/info "Request completed in" duration "ms with status" (:status response)) + (logging-port/log-info logging-service + (str "Request completed in " duration " ms with status " (:status response))) response))) -(defn wrap-exception [handler] +(defn wrap-exception [handler logging-service] (fn [request] (try (handler request) (catch Exception e - (logging/error "Unhandled exception:" (.getMessage e)) + (logging-port/log-error logging-service (str "Unhandled exception: " (.getMessage e))) {:status 500 :body {:error "Internal server error"}})))) -(defn create-app [evaluation-service] - (-> (make-handler evaluation-service) - (wrap-logging) - (wrap-exception) +(defn create-app [evaluation-service logging-service] + (-> (make-handler evaluation-service logging-service) + (wrap-logging logging-service) + (wrap-exception logging-service) (wrap-json-body {:keywords? false}) (wrap-json-response) (wrap-params))) \ No newline at end of file diff --git a/src/walue/core.clj b/src/walue/core.clj index 1463191..ad434d3 100644 --- a/src/walue/core.clj +++ b/src/walue/core.clj @@ -1,7 +1,8 @@ (ns walue.core (:require [ring.adapter.jetty :as jetty] [walue.adapter.http-adapter :as http] - [walue.port.evaluation-port :as port] + [walue.port.evaluation-port :as evaluation-port] + [walue.port.logging-port :as logging-port] [walue.infra.logging :as logging] [walue.infra.metrics :as metrics]) (:gen-class)) @@ -9,19 +10,21 @@ (def server-atom (atom nil)) (defn start-server [port] - (let [evaluation-service (port/->EvaluationService) - app (http/create-app evaluation-service) + (let [evaluation-service (evaluation-port/->EvaluationService) + logging-service (logging-port/->LoggingService) + app (http/create-app evaluation-service logging-service) metrics (metrics/init-metrics) server (jetty/run-jetty app {:port port :join? false})] (reset! server-atom server) - (logging/info "Server started on port" port) + (logging-port/log-info logging-service (str "Server started on port " port)) server)) (defn stop-server [] (when-let [server @server-atom] (.stop server) (reset! server-atom nil) - (logging/info "Server stopped"))) + (let [logging-service (logging-port/->LoggingService)] + (logging-port/log-info logging-service "Server stopped")))) (defn- get-env-var [name default] (if-let [value (System/getenv name)] @@ -33,7 +36,9 @@ (try (Integer/parseInt port-str) (catch NumberFormatException _ - (logging/warn "Invalid PORT environment variable value:" port-str "- using default 8080") + (let [logging-service (logging-port/->LoggingService)] + (logging-port/log-warn logging-service + (str "Invalid PORT environment variable value: " port-str " - using default 8080"))) 8080)))) (defn- get-log-level [] @@ -51,9 +56,10 @@ (catch NumberFormatException _ (get-port))) (get-port)) - log-level (get-log-level)] + log-level (get-log-level) + logging-service (logging-port/->LoggingService)] (logging/set-log-level! log-level) - (logging/info "Starting server with log level:" log-level) + (logging-port/log-info logging-service (str "Starting server with log level: " log-level)) (start-server port) (.addShutdownHook (Runtime/getRuntime) (Thread. ^Runnable stop-server)))) \ No newline at end of file diff --git a/src/walue/infra/fitness.clj b/src/walue/infra/fitness.clj new file mode 100644 index 0000000..f747891 --- /dev/null +++ b/src/walue/infra/fitness.clj @@ -0,0 +1,235 @@ +(ns walue.infra.fitness + (:require [clojure.tools.namespace.find :as find] + [clojure.java.io :as io] + [clojure.tools.namespace.file :as file] + [clojure.tools.namespace.parse :as parse] + [clojure.string :as str]) + (:import [java.io File])) + +;; Definições de camadas arquiteturais +(def layers + {:domain #{"walue.domain"} + :port #{"walue.port"} + :adapter #{"walue.adapter"} + :infra #{"walue.infra"} + :core #{"walue.core"}}) + +;; Regras de dependência permitidas entre camadas +(def allowed-deps + {:domain #{} ;; Domain não deve depender de nenhuma outra camada + :port #{:domain} ;; Port pode depender apenas de Domain + :adapter #{:domain :port} ;; Adapter pode depender de Domain e Port + :infra #{} ;; Infra não deve depender de outras camadas (exceto clojure.*) + :core #{:domain :port :adapter :infra}}) ;; Core pode depender de todas as camadas + +(defn- ns-to-layer + "Determina a qual camada um namespace pertence" + [ns-sym] + (let [ns-str (str ns-sym)] + (first + (for [[layer prefixes] layers + :when (some #(str/starts-with? ns-str %) prefixes)] + layer)))) + +(defn- get-clj-files + "Retorna todos os arquivos .clj no diretório" + [dir] + (find/find-clojure-sources-in-dir (io/file dir))) + +(defn- extract-namespace-and-deps + "Extrai o namespace e suas dependências de um arquivo .clj" + [file] + (when-let [ns-decl (file/read-file-ns-decl file)] + (when-let [ns-name (parse/name-from-ns-decl ns-decl)] + [ns-name (parse/deps-from-ns-decl ns-decl)]))) + +(defn check-layer-dependencies + "Verifica se as dependências entre camadas seguem as regras definidas" + [] + (let [src-dir "src" + files (get-clj-files src-dir) + ns-deps (keep extract-namespace-and-deps files) + violations (atom [])] + + (doseq [[ns-name deps] ns-deps] + (let [src-layer (ns-to-layer ns-name)] + (when src-layer + (doseq [dep deps] + (let [dep-layer (ns-to-layer dep)] + (when (and dep-layer + (not (contains? (get allowed-deps src-layer) dep-layer)) + (not= src-layer dep-layer)) + (swap! violations conj + {:source ns-name + :source-layer src-layer + :dependency dep + :dependency-layer dep-layer}))))))) + + {:valid? (empty? @violations) + :violations @violations})) + +(defn check-domain-purity + "Verifica se o domínio não tem dependências externas além da stdlib do Clojure" + [] + (let [src-dir "src/walue/domain" + files (get-clj-files src-dir) + ns-deps (keep extract-namespace-and-deps files) + violations (atom [])] + + (doseq [[ns-name deps] ns-deps] + (doseq [dep deps] + (let [dep-str (str dep)] + (when-not (or (str/starts-with? dep-str "clojure.") + (str/starts-with? dep-str "walue.domain")) + (swap! violations conj + {:namespace ns-name + :external-dependency dep}))))) + + {:valid? (empty? @violations) + :violations @violations})) + +(defn check-circular-dependencies + "Verifica a existência de dependências circulares" + [] + (let [src-dir "src" + files (get-clj-files src-dir) + ns-deps (into {} (keep extract-namespace-and-deps files)) + visited (atom #{}) + path (atom []) + cycles (atom [])] + + (letfn [(dfs [ns] + (when-not (contains? @visited ns) + (swap! visited conj ns) + (swap! path conj ns) + + (doseq [dep (get ns-deps ns)] + (if (some #{dep} @path) + ;; Encontrou um ciclo + (let [cycle-start (.indexOf @path dep) + cycle (conj (vec (drop cycle-start @path)) dep)] + (swap! cycles conj cycle)) + ;; Continue DFS + (dfs dep))) + + (swap! path pop)))] + + (doseq [ns (keys ns-deps)] + (reset! path []) + (dfs ns))) + + {:valid? (empty? @cycles) + :cycles @cycles})) + +(defn check-interface-isolation + "Verifica se as interfaces (portas) estão corretamente isoladas" + [] + (let [src-dir "src/walue/port" + files (get-clj-files src-dir) + violations (atom [])] + + (doseq [file files] + (let [content (slurp file) + file-name (.getName file)] + (when-not (re-find #"defprotocol" content) + (swap! violations conj + {:file file-name + :reason "Port file should define at least one protocol"})))) + + {:valid? (empty? @violations) + :violations @violations})) + +(defn check-adapter-implementation + "Verifica se os adaptadores implementam portas" + [] + (let [src-dir "src/walue/adapter" + files (get-clj-files src-dir) + violations (atom [])] + + (doseq [file files] + (let [content (slurp file) + file-name (.getName file) + has-port-dependency (or + (re-find #"require.*\[walue\.port" content) + (re-find #"walue\.port\.[a-z-]+\s+:as" content))] + (when-not has-port-dependency + (swap! violations conj + {:file file-name + :reason "Adapter should depend on at least one port"})))) + + {:valid? (empty? @violations) + :violations @violations})) + +(defn run-fitness-checks + "Executa todas as verificações de fitness e retorna o resultado completo" + [] + (let [layer-deps-result (check-layer-dependencies) + domain-purity-result (check-domain-purity) + circular-deps-result (check-circular-dependencies) + interface-result (check-interface-isolation) + adapter-result (check-adapter-implementation) + all-valid? (and (:valid? layer-deps-result) + (:valid? domain-purity-result) + (:valid? circular-deps-result) + (:valid? interface-result) + (:valid? adapter-result))] + + {:all-valid? all-valid? + :layer-dependencies layer-deps-result + :domain-purity domain-purity-result + :circular-dependencies circular-deps-result + :interface-isolation interface-result + :adapter-implementation adapter-result})) + +(defn -main + "Ponto de entrada para verificação de fitness da arquitetura" + [& args] + (let [results (run-fitness-checks) + all-valid? (:all-valid? results)] + + (println "\n=== Architectural Fitness Check Results ===\n") + + ;; Verifica dependências entre camadas + (let [{:keys [valid? violations]} (:layer-dependencies results)] + (println "Layer Dependencies Check:" (if valid? "PASSED ✓" "FAILED ✗")) + (when-not valid? + (println " Violations:") + (doseq [v violations] + (println (str " - " (:source v) " (" (name (:source-layer v)) ") depends on " + (:dependency v) " (" (name (:dependency-layer v)) ")"))))) + + ;; Verifica pureza do domínio + (let [{:keys [valid? violations]} (:domain-purity results)] + (println "\nDomain Purity Check:" (if valid? "PASSED ✓" "FAILED ✗")) + (when-not valid? + (println " Violations:") + (doseq [v violations] + (println (str " - " (:namespace v) " depends on external " (:external-dependency v)))))) + + ;; Verifica dependências circulares + (let [{:keys [valid? cycles]} (:circular-dependencies results)] + (println "\nCircular Dependencies Check:" (if valid? "PASSED ✓" "FAILED ✗")) + (when-not valid? + (println " Cycles detected:") + (doseq [cycle cycles] + (println (str " - " (str/join " -> " cycle)))))) + + ;; Verifica isolamento de interfaces + (let [{:keys [valid? violations]} (:interface-isolation results)] + (println "\nInterface Isolation Check:" (if valid? "PASSED ✓" "FAILED ✗")) + (when-not valid? + (println " Violations:") + (doseq [v violations] + (println (str " - " (:file v) ": " (:reason v)))))) + + ;; Verifica implementação de adaptadores + (let [{:keys [valid? violations]} (:adapter-implementation results)] + (println "\nAdapter Implementation Check:" (if valid? "PASSED ✓" "FAILED ✗")) + (when-not valid? + (println " Violations:") + (doseq [v violations] + (println (str " - " (:file v) ": " (:reason v)))))) + + (println "\nOverall Result:" (if all-valid? "PASSED ✓" "FAILED ✗")) + + (System/exit (if all-valid? 0 1)))) \ No newline at end of file diff --git a/src/walue/infra/logging_interceptor.clj b/src/walue/infra/logging_interceptor.clj new file mode 100644 index 0000000..1ffa8bf --- /dev/null +++ b/src/walue/infra/logging_interceptor.clj @@ -0,0 +1,30 @@ +(ns walue.infra.logging-interceptor + (:require [walue.infra.logging :as logging]) + (:import [java.time LocalDateTime] + [java.time.format DateTimeFormatter])) + +(defn log-request + "Interceptor para logar requisições HTTP" + [uri method] + (logging/info "Request received:" method uri)) + +(defn log-response + "Interceptor para logar respostas HTTP" + [start-time status] + (let [duration (- (System/currentTimeMillis) start-time)] + (logging/info "Request completed in" duration "ms with status" status))) + +(defn log-evaluation + "Interceptor para logar avaliação de portfólio" + [portfolio criteria] + (logging/info "Evaluating portfolio with" (count portfolio) "assets and" (count criteria) "criteria")) + +(defn log-evaluation-completed + "Interceptor para logar conclusão da avaliação de portfólio" + [] + (logging/info "Portfolio evaluation completed")) + +(defn log-error + "Interceptor para logar erros" + [error-message] + (logging/error error-message)) \ No newline at end of file diff --git a/src/walue/port/evaluation_port.clj b/src/walue/port/evaluation_port.clj index caee53e..8d1b825 100644 --- a/src/walue/port/evaluation_port.clj +++ b/src/walue/port/evaluation_port.clj @@ -1,6 +1,5 @@ (ns walue.port.evaluation-port - (:require [walue.domain.evaluation :as evaluation] - [walue.infra.logging :as logging])) + (:require [walue.domain.evaluation :as evaluation])) (defprotocol EvaluationPort "Port for portfolio evaluation services" @@ -9,7 +8,5 @@ (defrecord EvaluationService [] EvaluationPort (evaluate-portfolio [_ portfolio criteria] - (logging/info "Evaluating portfolio with" (count portfolio) "assets and" (count criteria) "criteria") (let [result (evaluation/evaluate-portfolio portfolio criteria)] - (logging/info "Portfolio evaluation completed") {:resultado result}))) \ No newline at end of file diff --git a/src/walue/port/logging_port.clj b/src/walue/port/logging_port.clj new file mode 100644 index 0000000..9df3bfe --- /dev/null +++ b/src/walue/port/logging_port.clj @@ -0,0 +1,19 @@ +(ns walue.port.logging-port) + +(defprotocol LoggingPort + "Porta para serviços de logging" + (log-info [this message] "Registra uma mensagem informativa") + (log-warn [this message] "Registra uma mensagem de aviso") + (log-error [this message] "Registra uma mensagem de erro") + (log-debug [this message] "Registra uma mensagem de debug")) + +(defrecord LoggingService [] + LoggingPort + (log-info [_ message] + (println (str "{\"level\":\"info\",\"message\":\"" message "\"}"))) + (log-warn [_ message] + (println (str "{\"level\":\"warn\",\"message\":\"" message "\"}"))) + (log-error [_ message] + (println (str "{\"level\":\"error\",\"message\":\"" message "\"}"))) + (log-debug [_ message] + (println (str "{\"level\":\"debug\",\"message\":\"" message "\"}")))) \ No newline at end of file diff --git a/test/walue/adapter/http_adapter_test.clj b/test/walue/adapter/http_adapter_test.clj index 65d07e6..1a931f3 100644 --- a/test/walue/adapter/http_adapter_test.clj +++ b/test/walue/adapter/http_adapter_test.clj @@ -3,15 +3,17 @@ [ring.mock.request :as mock] [clojure.data.json :as json] [walue.adapter.http-adapter :as http] - [walue.port.evaluation-port :as port])) + [walue.port.evaluation-port :as evaluation-port] + [walue.port.logging-port :as logging-port])) (defn- parse-json-body [response] (json/read-str (:body response) :key-fn keyword)) (deftest http-adapter-integration-test (testing "Portfolio evaluation endpoint" - (let [evaluation-service (port/->EvaluationService) - app (http/create-app evaluation-service) + (let [evaluation-service (evaluation-port/->EvaluationService) + logging-service (logging-port/->LoggingService) + app (http/create-app evaluation-service logging-service) request-body {:portfolio [{:ticker "PETR4" :pl 8.5 :tag_along 80 @@ -52,8 +54,9 @@ (is (= 4.5 (:score (second (:resultado body)))))) (testing "Health check endpoint" - (let [evaluation-service (port/->EvaluationService) - app (http/create-app evaluation-service) + (let [evaluation-service (evaluation-port/->EvaluationService) + logging-service (logging-port/->LoggingService) + app (http/create-app evaluation-service logging-service) request (mock/request :get "/health") response (app request) body (parse-json-body response)] @@ -62,8 +65,9 @@ (is (= "UP" (:status body))))) (testing "Invalid request handling" - (let [evaluation-service (port/->EvaluationService) - app (http/create-app evaluation-service) + (let [evaluation-service (evaluation-port/->EvaluationService) + logging-service (logging-port/->LoggingService) + app (http/create-app evaluation-service logging-service) request (-> (mock/request :post "/api/evaluate") (mock/json-body {:invalid "request"})) response (app request) diff --git a/test/walue/infra/fitness_test.clj b/test/walue/infra/fitness_test.clj new file mode 100644 index 0000000..8d2ad1c --- /dev/null +++ b/test/walue/infra/fitness_test.clj @@ -0,0 +1,34 @@ +(ns walue.infra.fitness-test + (:require [clojure.test :refer :all] + [walue.infra.fitness :as fitness])) + +(deftest test-architectural-fitness + (testing "Layer dependencies compliance" + (let [result (fitness/check-layer-dependencies)] + (is (:valid? result) + (str "Layer dependencies violated: " + (pr-str (:violations result)))))) + + (testing "Domain purity" + (let [result (fitness/check-domain-purity)] + (is (:valid? result) + (str "Domain purity violated: " + (pr-str (:violations result)))))) + + (testing "No circular dependencies" + (let [result (fitness/check-circular-dependencies)] + (is (:valid? result) + (str "Circular dependencies detected: " + (pr-str (:cycles result)))))) + + (testing "Interface isolation" + (let [result (fitness/check-interface-isolation)] + (is (:valid? result) + (str "Interface isolation violated: " + (pr-str (:violations result)))))) + + (testing "Adapter implementation" + (let [result (fitness/check-adapter-implementation)] + (is (:valid? result) + (str "Adapter implementation violated: " + (pr-str (:violations result))))))) \ No newline at end of file