- [rust, design, architecture, fp]

Expressando o domínio através do sistema de tipos

(Note: This post should be Google Translate friendly. I’ve refrained from using slangs to help with that.)

Este post é para que eu possa desenvolver melhor a ideia sobre a relação de tipos e domínio, além de mostrar um pouco, de forma mais prática, as vantagens de utilizar um sistema de tipos expressivo, em relação a sistemas de tipos mais simples.

Antes de tudo, vamos definir um pouco quais tipos entraram na categoria de sistema de tipos expressivo. Muitos dos sistemas de tipo que considero expressivo geralmente não são os que estamos acostumados, e que geralmente vem na nossa mente, como Java ou C#.

O Gary Bernhardt escreveu um Gist interessante sobre “Types” em que é possível entender diferentes aspectos de cada linguagem.

Esse não é um post para dizer que utilizando uma linguagem como JavaScript, Python ou Ruby não poderíamos chegar na mesma arquitetura. É um post para analisar como a existência do sistema de tipos estimula um outro tipo de design.

Sistemas de tipos mais dinâmicos possuem suas vantagens, mas vou focar nas vantagens dos tipos estáticos, sem compará-los diretamente - talvez isso seja um assunto para outro artigo.

Demonstrarei a seguir um dos aspectos de um sistema de tipo mais restritivo e como aproveitar o investimento que é “definir melhor o seu programa” - mesmo que isso queira dizer um pouco mais verboso e enrijecido.

Qual “Sistema estático” vou utilizar?

Esse post foi pensado em Rust, que tem algumas características um pouco peculiares, se comparados com o sistema de tipos estático mais comuns (Java ou C#).

Algumas das características peculiares se comparado com essas linguagens:

Null não é um habitante de todos os Tipos.

Em Java, por exemplo, a seguinte assinatura de método requer que o corpo da função seja inspecionado para descobrir se null pode ser retornado.

public static class File {
    public static File open(String path) {
        //....
    }
}

Em Rust, não temos null. Funções que retornariam null em outras linguagens precisam retornar em Rust uma estrutura, indicando que o valor é opcional.

mod file {
    pub fn open(path: String) -> Option<File> {
        // ....
    }
}

Assim, é preciso verificar se o valor está presente ou não, antes de continuar o processamento. Essa declaração agora também é parte da assinatura, e nos informa da possível falta do valor retornado.

É possível expressar que um valor será invalidado após uma operação.

Rust possui o conceito de posse do valor (em inglês, ownership), que define que um escopo precisa ser dono de seus valores, invalidando qualquer outra referência, caso não seja possível copiar ou clonar o dado ao trocar de escopo. Isso traz mais expressividade, mas ao mesmo tempo um conceito a mais para aprender.

A ideia de posse do valor é um conceito que Rust traz como novidade em comparação a outras linguagens, e que para mim é um ótimo motivo para estudar a linguagem. O conceito é um pouco difícil, inclusive por ser novidade, e mais pra frente no texto trarei alguns exemplos.

Tratar essas peculiaridades como benefícios, como sempre, são escolhas de benefício x valor. Vamos ver o que é possível expressar utilizando essas características, para podermos considerar o valor.

Para isso, vamos escrever código e observar o que é conseguimos entender do domínio, mesmo sem ter o corpo das funções.

Como compilar os exemplos de código

Trarei exemplos de código em Rust, com links para o exemplo completo de cada transformação.

É preciso ter instalado no seu computador o compilador do Rust para compilar os exemplos e recomendo seguir as instruções do rustup.rs para ter instalado o rustc.

Pela linha de comando você então poderá chamar o compilador de uma das seguinte maneiras:

# Chamada simples
rustc exemplo.rs

# Chamada ignorando alguns alertas por não utilizarmos os argumentos ou funções
rustc -A unused_variables -A dead_code exemplo.rs

Domínio do problema

Vou escolher problemas em um domínio com algumas características arbitrárias (descritas abaixo) para exercitar melhor os conceitos que o sistema de tipos de Rust possui.

Estamos escrevendo um sistema de pedidos.


  • Nossos pedidos acontecem por uma API em um servidor
  • Um pedido não pode ter quantidades negativas
  • Como podemos ficar fora de estoque, em caso de erro devemos voltar ao produto com uma mensagem
    • É preciso iniciar o processo inteiro novamente e nenhuma outra thread poderia usar nosso pedido
  • Toda transação precisa receber um token de sessão
  • Toda sessão é iniciada por um token de autorização

Depois dessas regras arbitrárias, vamos ver o que é possível descrever do nosso domínio apenas pelo sistema de tipos e regras de visibilidade.

A implementação das funções não importa no momento.

Vamos observar quais informações podemos extrair das assinaturas, como se estivéssemos utilizando uma biblioteca de um terceiro.

Implementação inicial

pub fn authorize(auth_token: String) -> String {
    unimplemented!()
}

pub fn send_order(session_token: String,
                  amount: u8,
                  product: String) {
    unimplemented!()
}

fn main() {
    let session_token = authorize("My initial token".into());
    send_order(session_token, 10, "Bananas".into())
}

Essa é uma implementação inicial e pode ser amadurecida.

  • Temos nossa função principal do problema, send_order, com os conceitos de: quantidade, produto e token de sessão.
  • Existe uma função que pode gerar um token de sessão.

Estamos com essas duas funções, mas de nenhuma forma estou definindo no programa que essas funções mantém uma relação bem próxima.

Sem essa definição de relação, uma outra pessoa:

  • Poderia chamar send_order sem chamar a função authorize
  • Poderia chamar a função send_order com uma String arbitrária como token, e ter um erro de parsing, validação ou qualquer outra coisa.

Vamos deixar nosso programa melhor definido escrevendo mais código.

Extraindo o conceito de Session Token

Temos um relacionamento entre a String de saída do authorize, e a String na entrada do send_order. No nosso domínio, isso é o token da sessão.

Vamos fazer uma pequena alteração no código, e extrair o conceito do token da sessão em uma estrutura retornada ao iniciar a sessão.

Vou mostrar um diff da mudança, e um link para o código pronto para ser compilado.

diff --git a/order_01.rs b/order_02.rs
index cfce64f..9b95b16 100644
--- a/order_01.rs
+++ b/order_02.rs
@@ -1,8 +1,10 @@
-pub fn authorize(auth_token: String) -> String {
+pub struct SessionToken(String);
+
+pub fn authorize(auth_token: String) -> SessionToken {
     unimplemented!()
 }

-pub fn send_order(session_token: String,
+pub fn send_order(session_token: SessionToken,
                   amount: u8,
                   product: String) {
     unimplemented!()

Sem se ater muito aos detalhes, esse novo snippet introduz uma estrutura que encapsula uma String. A nossa estrutura SessionToken faz a conexão entre o retorno de authorize com a entrada de send_order.

Ao analisarmos as assinaturas, a conexão entre as duas funções agora vai além dos nomes e entra no nível de estruturas de dados que o compilador pode verificar.

Nosso código de uso no main se manteve o mesmo.

Fica mais intuitivo (e com a ajuda de um auto-complete) associar as duas funções. Menos um erro de runtime.

Ainda podemos chamar a nossa função com uma String arbitrária, caso seja necessário, como em um teste.

send_order(SessionToken("My test token".into()));

Reutilizando o Session Token

Um problema interessante acontece se tentarmos fazer dois pedidos compartilhando o mesmo token com o código anterior:

fn main() {
    let session_token = authorize("My initial token".into());
    send_order(session_token, 10, "Bananas".into());
    // Adicionamos mais um pedido
    send_order(session_token, 5, "Peras".into());
}

Ao compilar o programa, temos o seguinte erro:

$ rustc ~/order.rs

error[E0382]: use of moved value: `session_token`
  --> /Users/bruno/order.rs:16:16
   |
15 | 	send_order(session_token, 10, "Bananas".into());
   |            	------------- value moved here
16 | 	send_order(session_token, 5, "Peras".into());
   |            	^^^^^^^^^^^^^ value used here after move
   |
   = note: move occurs because `session_token` has type `SessionToken`, which does not implement the `Copy` trait
   error: aborting due to previous error

Toda essa mensagem de erro está relacionada ao conceito de posse do valor que Rust tem.

Da forma que a assinatura da nossa função está escrita, temos que enviar todo o valor, junto com o registro posse do SessionToken para fazer um pedido.

A posse do valor do token pertence à variável com o escopo em main. Ao chamarmos a função send_order pela primeira vez, esse valor é movido para o escopo na primeira chamada de send_order e não está mais disponível para fazermos mais um pedido.

Como a função session_token só precisa do token emprestado (em inglês, borrowing), precisamos mudar a assinatura da nossa função a fim de demonstrar a intenção que queremos o valor temporariamente e que não vamos reescrever ou alterar o token, só vamos pegar emprestado para poder fazer o pedido.

diff --git a/order_02.rs b/order_03.rs
index 9b95b16..f3f939e 100644
--- a/order_02.rs
+++ b/order_03.rs
@@ -4,7 +4,7 @@ pub fn authorize(auth_token: String) -> SessionToken {
     unimplemented!()
 }

-pub fn send_order(session_token: SessionToken,
+pub fn send_order(session_token: &SessionToken,
                   amount: u8,
                   product: String) {
     unimplemented!()
@@ -12,5 +12,6 @@ pub fn send_order(session_token: SessionToken,

 fn main() {
     let session_token = authorize("My initial token".into());
-    send_order(session_token, 10, "Bananas".into())
+    send_order(&session_token, 10, "Bananas".into());
+    send_order(&session_token, 5, "Peras".into());
 }

A mudança é pequena na assinatura: trocando de SessionToken para &SessionToken e corrigindo como passamos o argumento do token. Segue o link para copiar e compilar o código completo.

Temos agora definido no nível da assinatura que não vamos alterar o valor da variável session_token ao chamar send_order e que um mesmo token pode ser reutilizado, inclusive compartilhado por várias threads ao realizar o pedido.

Expondo apenas uma maneira de criar um Session Token válido

Ainda lidando com o conceito de SessionToken, senti a necessidade de tornar o relacionamento entre authorize e send_order mais forte.

Com o código anterior, ainda seria possível criar um token inválido:

fn main() {
    // Session tokens precisam seguir um formato específico
    // ASDF não deveria ser um token válido
    send_order(SessionToken("ASDF".into()), 10, "Bananas".into());
}

A estrutura SessionToken no exemplo tem um token inválido, em um formato que não seria aceito pelas APIs. Se utilizarmos uma restrição na visibilidade do que é exportado, podemos definir que SessionTokens sejam criados só se forem válidos.

Como temos todo o código no mesmo arquivo, todas os construtores e funções estarão disponíveis para a função main nesse momento.

Em Rust, além de podermos utilizar um outro arquivo para criar módulos, é possível criar um módulo no mesmo arquivo. Vamos introduzir um módulo para controlarmos melhor quais construtores estarão visíveis.

diff --git a/order_03.rs b/order_04.rs
index f3f939e..c31b445 100644
--- a/order_03.rs
+++ b/order_04.rs
@@ -1,3 +1,4 @@
+mod lib {
     pub struct SessionToken(String);

     pub fn authorize(auth_token: String) -> SessionToken {
@@ -9,6 +10,9 @@ pub fn send_order(session_token: &SessionToken,
                       product: String) {
         unimplemented!()
     }
+}
+
+pub use lib::*;

 fn main() {
     let session_token = authorize("My initial token".into());

Criamos um módulo lib ao redor do nosso código, e no escopo do arquivo, importamos apenas as funções públicas com pub use lib::*.

Apesar da nossa estrutura ser pública, o campo interno de dados não é.

error[E0450]: cannot invoke tuple struct constructor with private fields
  --> ~/order.rs:18:15
   |
2  | 	pub struct SessionToken(String);
   |                         	------- private field declared here
...
18 |   	let s = SessionToken("ASDF".into());
   |           	^^^^^^^^^^^^ cannot construct with a private field

error: aborting due to previous error

Tanto o acesso para leitura e escrita dos campos privados da estrutura estarão disponíveis apenas para as funções dentro do módulo. Assim, caso o desenvolvedor queira um SessionToken, é preciso chamar authorize.

E como send_order precisa de um token, a relação entre as duas funções é mais forte e validada pelo compilador.

Experimentem descomentar a linha comentada no exemplo no main, e ver o erro.

Extraindo o conceito de Pedido

Uma regra do domínio que está escrita nas entrelinhas é que temos o conceito de um pedido válido. Deveríamos ter apenas pedidos com números positivos, já que não podemos entregar -10 maçãs.

Como no passo anterior, podemos extrair o conceito de Pedido em uma estrutura, e prover apenas uma maneira de criar essa estrutura, que requer validação da quantidade.

Vamos precisar de alguns passos intermediários para poder chegar lá.

Primeiro, vamos criar uma estrutura que encapsula o conceito de pedido, chamada Order.

diff --git a/order_04.rs b/order_06.rs
index c31b445..47f56d9 100644
--- a/order_04.rs
+++ b/order_06.rs
@@ -1,13 +1,17 @@
 mod lib {
     pub struct SessionToken(String);

+    pub struct Order {
+        pub amount: u8,
+        pub name: String,
+    }
+
     pub fn authorize(auth_token: String) -> SessionToken {
         unimplemented!()
     }

     pub fn send_order(session_token: &SessionToken,
-                      amount: u8,
-                      product: String) {
+                      order: &Order) {
         unimplemented!()
     }
 }
@@ -16,6 +20,7 @@ pub use lib::*;

 fn main() {
     let session_token = authorize("My initial token".into());
-    send_order(&session_token, 10, "Bananas".into());
-    send_order(&session_token, 5, "Peras".into());
+
+    let first_order = Order { amount: 10, name: "Bananas".into() };
+    send_order(&session_token, &first_order);
 }

O código completo para compilar está aqui.

Criando apenas Pedidos válidos

Agora com nossa estrutura sendo utilizada pelo main e pelo send_order, podemos permitir que pedidos tenham uma quantidade válida para criar um Order.

Da mesma maneira que fizemos com a estrutura do SessionToken, podemos transformar a estrutura interna privada, permitindo que apenas uma função dentro do módulo acessem os campos.

Vamos criar uma função send_order, que valida, cria e retorna nossa estrutura Order. Isso seria como um construtor, mas que inclui as regras de validação.

Com as regras de visibilidade, esse será o único método que retorna a estrutura Order no nosso módulo.

diff --git a/order_06.rs b/order_07.rs
index 47f56d9..a13f381 100644
--- a/order_06.rs
+++ b/order_07.rs
@@ -2,8 +2,15 @@ mod lib {
     pub struct SessionToken(String);

     pub struct Order {
-        pub amount: u8,
-        pub name: String,
+        amount: u8,
+        name: String,
+    }
+
+    pub fn create_order(amount: u8, name: String) -> Order {
+        if amount <= 0 {
+            unimplemented!()
+        }
+        unimplemented!()
     }

     pub fn authorize(auth_token: String) -> SessionToken {
@@ -21,6 +28,6 @@ pub use lib::*;
 fn main() {
     let session_token = authorize("My initial token".into());

-    let first_order = Order { amount: 10, name: "Bananas".into() };
+    let first_order = create_order(10, "Bananas".into());
     send_order(&session_token, &first_order);
 }

Criamos um relacionamento forte entra a saida de create_order com a entrada de send_order, assim como fizemos anteriormente.

O código completo para compilar está aqui.

Indicando que um pedido pode ser inválido

Uma pergunta surgiu com o código anterior: O que acontece se a validação falhar?

Como eu não posso retornar nulos (Rust não tem nulo) e nem lançar exceções (Rust não tem exceções), tenho duas opções:

  1. Abortar o programa inteiro (eg: panic!)
  2. Retornar uma estrutura de dados que indica a possibilidade de falha da nossa operação

A opção 1 é não é ideal. Eu não gostaria que meu programa falhasse completamente apenas por ter um pedido inválido. Além do mais, nossas regras de negócio possuem instruções sobre o que fazer em caso de erro.

Precisamos de estratégias para lidar com pedidos inválidos.

Vamos aproveitar uma estrutura chamada Result que está disponível na stdlib da linguagem. Nós poderíamos reescrever essa estrutura nós mesmos, mas já existem várias funcionalidades que ganhamos ao utilizar a estrutura da stdlib.

O conceito de Result<T, U> é uma estrutura que tem duas variações de tipos. Temos o Result::Ok(T), que envolve o valor em caso de sucesso, e o Result::Err(U) com o valor em caso de erro.

Um valor com tipo Result<Order, String> significa:

  • Caso a operação tenha dado certo, Result::Ok(Order), você poderá extrair um valor do tipo Order;
  • E caso tenha um erro, Result::Err(String), você tera um valor do tipo String.
diff --git a/order_07.rs b/order_08.rs
index a13f381..8521912 100644
--- a/order_07.rs
+++ b/order_08.rs
@@ -6,7 +6,7 @@ mod lib {
         name: String,
     }

-    pub fn create_order(amount: u8, name: String) -> Order {
+    pub fn create_order(amount: u8, name: String) -> Result<Order, String> {
         if amount <= 0 {
             unimplemented!()
         }
@@ -29,5 +29,8 @@ fn main() {
     let session_token = authorize("My initial token".into());

     let first_order = create_order(10, "Bananas".into());
-    send_order(&session_token, &first_order);
+
+    if let Ok(order) = first_order {
+        send_order(&session_token, &order);
+    }
 }

Com a assinatura atualizada, sou obrigado a considerar alguma estratégia para verificar se o pedido foi criado corretamente. A estratégia poderia ser falhar o programa em caso de erros chamando .unwrap(), mas vou utilizar pattern matching, e apenas enviar o pedido caso eu tenha um resultado Ok no main.

O código completo para compilar está aqui.

Aproveitamos e criaremos uma estrutura bem específica para que possamos comunicar qual tipo de erro aconteceu ao criar nosso pedido. Assim, a assinatura do nosso método fica mais explícita sobre os possíveis tipos de erro, ao invés de ser uma String qualquer.

A estrutura chamada InvalidOrder terá a uma mensagem de erro, e encapsula bem o domínio do possível erro na nossa função.

diff --git a/order_08.rs b/order_09.rs
index 8521912..8d9b087 100644
--- a/order_08.rs
+++ b/order_09.rs
@@ -1,12 +1,14 @@
 mod lib {
     pub struct SessionToken(String);

+    pub struct InvalidOrder(String);
+
     pub struct Order {
         amount: u8,
         name: String,
     }

-    pub fn create_order(amount: u8, name: String) -> Result<Order, String> {
+    pub fn create_order(amount: u8, name: String) -> Result<Order, InvalidOrder> {
         if amount <= 0 {
             unimplemented!()
         }

O código completo para compilar está aqui.

Trazendo o mesmo conceito de possível falha ao iniciar uma sessão

Aprendemos no passo anterior que é possível expressar possíveis falhas como parte da assinatura das funções.

Pedir um token de sessão envolve fazer uma chamada a um serviço, então podemos ter falhas que deveriam ser comunicados ao desenvolvedor para que tomem uma decisão sobre o que fazer.

As razões de erro podem ser inúmeras nesse caso. Por exemplo, podemos ter um erro ao fazer o parsing do JSON ou a nossa conexão cair.

Essa enumeração dos erros que vamos nos preocupar pode ser descrita por um enum.

diff --git a/order_09.rs b/order_10.rs
index 8d9b087..b6290cb 100644
--- a/order_09.rs
+++ b/order_10.rs
@@ -3,6 +3,11 @@ mod lib {

     pub struct InvalidOrder(String);

+    pub enum ApiError {
+        ParsingError(String),
+        IoError(String),
+    }
+
     pub struct Order {
         amount: u8,
         name: String,

O código completo para compilar está aqui.

Com a nossa lista de possíveis erros, agora podemos alterar a assinatura do método para informar que pedir um token pode falhar.

Essa mudança na assinatura também requer uma mudança no main.

diff --git a/order_10.rs b/order_11.rs
index b6290cb..1958286 100644
--- a/order_10.rs
+++ b/order_11.rs
@@ -20,7 +20,7 @@ mod lib {
         unimplemented!()
     }

-    pub fn authorize(auth_token: String) -> SessionToken {
+    pub fn authorize(auth_token: String) -> Result<SessionToken, ApiError> {
         unimplemented!()
     }

@@ -33,7 +33,7 @@ mod lib {
 pub use lib::*;

 fn main() {
-    let session_token = authorize("My initial token".into());
+    if let Ok(session_token) = authorize("My initial token".into()) {

         let first_order = create_order(10, "Bananas".into());

@@ -41,3 +41,4 @@ fn main() {
             send_order(&session_token, &order);
         }
     }
+}

Como só posso continuar com o processo e fazer o pedido caso a autorização estaja Ok, utilizamos a mesma estratégia de pattern matching que utilizamos ao criar o pedido.

Invalidando uma ordem depois que ela é enviada

Revisando a lista de problemas que temos para resolver:


  • Nossos pedidos acontecem por uma API em um servidor
  • Um pedido não pode ter quantidades negativas
  • Como podemos ficar fora de estoque, em caso de erro devemos voltar ao produto com uma mensagem
    • É preciso iniciar o processo inteiro novamente e nenhuma outra thread poderia usar nosso pedido
  • Toda transação precisa receber um token de sessão
  • Toda sessão é iniciada por um token de autorização

Temos bem claro que depois que um pedido é feito e temos um erro, deveríamos iniciar o fluxo novamente. Assim, não tentamos fazer o mesmo pedido com um número maior que o estoque, por exemplo.

Isso pode ser interpretado da seguinte maneira: assim que eu enviar o pedido, independente do resultado, eu não deveria enviar o mesmo Pedido.

Se imaginarmos que nosso código será usado em um ambiente com multi-thread, poderíamos trazer essa regra para a nossa assinatura e fazer com que o compilador reforce essa regra. Se uma thread enviar um pedido, outra thread não poderá enviar o mesmo pedido.

Como em Rust temos o conceito de ownership que falamos antes, podemos expressar isso pela assinatura. Alterando a assinatura em send_order, ao invés de pegar emprestado o valor do Pedido, podemos pedir a posse do valor também.

Com a mudança de &Order para Order, transmitimos que o pedido não estará mais disponível depois de chamar send_order, dado que o valor da variável será movido para outro contexto.

diff --git a/order_11.rs b/order_12.rs
index 1958286..dbae30a 100644
--- a/order_11.rs
+++ b/order_12.rs
@@ -25,7 +25,7 @@ mod lib {
     }

     pub fn send_order(session_token: &SessionToken,
-                      order: &Order) {
+                      order: Order) {
         unimplemented!()
     }
 }
@@ -38,7 +38,7 @@ fn main() {
         let first_order = create_order(10, "Bananas".into());

         if let Ok(order) = first_order {
-            send_order(&session_token, &order);
+            send_order(&session_token, order);
         }
     }
 }

Nosso caso para o Order é o inverso do que esperamos para o token ao fazer um pedido. Nós gostaríamos de compartilhar o mesmo token com vários envios, mas o mesma estrutura de pedido não deveria ser reutilizada.

Nesse caso, gosto de pensar que o pedido foi “consumido” por send_order, invalidando que outras partes do código utilize um valor já enviado.

Na maioria dos casos, os problemas irão preferir utilizar o valor “emprestado”, mas as nossas regras arbitrárias geraram esse cenário e gostaria de compartilhar esse exemplo com vocês.

O código completo para compilar está aqui. Descomente a linha no exemplo para ver o compilador reforçando que nosso pedido não pode mais ser utilizado.

$ rustc -A unused_variables -A dead_code ~/order.rs
error[E0382]: use of moved value: `order`
--> ~/order_13.rs:43:28
   |
41 |         	send_order(&session_token, order);
   |                                    	----- value moved here
42 |         	// Tente descomentar para falhar
43 | send_order(&session_token, order);
   |                        	^^^^^ value used here after move
   |
   = note: move occurs because `order` has type `lib::Order`, which does not implement the `Copy` trait

error: aborting due to previous error

Trazendo uma resposta sobre o resultado do Pedido

Nosso domínio traz regras sobre o que fazer em caso de erro ao fazer um pedido. Nossa assinatura deveria refletir as nossas intenções e demonstrar que existe uma resposta e sobre a possível falha ao fazer um pedido.

Primeiro, vamos converter a resposta em JSON para uma estrutura na linguagem.

diff --git a/order_12.rs b/order_14.rs
index dbae30a..4277e4c 100644
--- a/order_12.rs
+++ b/order_14.rs
@@ -13,6 +13,12 @@ mod lib {
         name: String,
     }

+    pub struct OrderResponse {
+        pub name: String,
+        pub status: String,
+        pub amount: u8,
+    }
+
     pub fn create_order(amount: u8, name: String) -> Result<Order, InvalidOrder> {
         if amount <= 0 {
             unimplemented!()
@@ -25,7 +31,7 @@ mod lib {
     }

     pub fn send_order(session_token: &SessionToken,
-                      order: Order) {
+                      order: Order) -> OrderResponse {
         unimplemented!()
     }
 }

Também vamos indicar que nosso envio do pedido pode falhar, assim como acontece ao iniciar uma sessão.

diff --git a/order_14.rs b/order_15.rs
index 4277e4c..dee3edd 100644
--- a/order_14.rs
+++ b/order_15.rs
@@ -31,7 +31,7 @@ mod lib {
     }

     pub fn send_order(session_token: &SessionToken,
-                      order: Order) -> OrderResponse {
+                      order: Order) -> Result<OrderResponse, ApiError> {
         unimplemented!()
     }
 }

O código completo para compilar está aqui.

Uma grande vantagem de utilizar a estrutura Result que vem junto da stdlib, é que o compilador entende a semântica de erros. Nosso código faz uma chamada que pode falhar ao enviar o pedido, mas nunca está verificando se a resposta está Ok.

O compilador sabe que Result tem a semântica de uma operação que pode falhar, e nos avisa se não utilizamos o valor.

Obrigado rustc!

$ rustc -A unused_variables -A dead_code ~/order.rs
warning: unused result which must be used, #[warn(unused_must_use)] on by default
  --> ~/order.rs:46:13
   |
46 |         	send_order(&session_token, order);
   |         	^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Definindo possíveis status de um pedido

Ainda aproveitando para demonstrar a expressividade do sistema de tipos, podemos definir melhor quais os possíveis estados de um pedido.

Ao invés de aceitar qualquer tipo de String, vamos criar uma enumeração com todos os possíveis estados que nos importamos.

Uma vantagem de utilizar um enum é não precisamos nos preocupar se devemos utilizar números, strings em minúsculo, strings em maiúsculo, capitalizadas, etc. ao criar um pedido.

Agora também estamos permitindo que:

  • alguém que esteja explorando a documentação saiba os possíveis estados;
  • que o compilador verifique se cobrimos todos os casos em um pattern match;
  • a responsabilidade de transformar os valores a serem transmitidos e parseados para a parte que seja delegada para a parte de comunicação do programa.

Vamos introduzir a estrutura OrderStatus no nosso código.

diff --git a/order_15.rs b/order_16.rs
index dee3edd..b4b6269 100644
--- a/order_15.rs
+++ b/order_16.rs
@@ -15,10 +15,17 @@ mod lib {

     pub struct OrderResponse {
         pub name: String,
-        pub status: String,
+        pub status: OrderStatus,
         pub amount: u8,
     }

+    pub enum OrderStatus {
+        Waiting,
+        Shipping,
+        Shipped,
+        Delivered,
+    }
+
     pub fn create_order(amount: u8, name: String) -> Result<Order, InvalidOrder> {
         if amount <= 0 {
             unimplemented!()

Link para o código pronto para ser compilado.

Dando um nome mais bonito para nossas respostas da API

Assim que começarmos a criar mais e mais funções que utilizam comunicação com nossa API, veremos o tipo Result<T, ApiError>, várias e várias vezes. Inclusive, já temos duas funções com esse retorno na assinatura.

Vamos criar um tipo ApiResponse para que todos saibam que essa é uma chamada para a API, e que todas as respostas que tem esse tipo, terão os mesmo possíveis erros para se preocupar.

diff --git a/order_16.rs b/order_17.rs
index b4b6269..c064e9c 100644
--- a/order_16.rs
+++ b/order_17.rs
@@ -26,6 +26,8 @@ mod lib {
         Delivered,
     }

+    pub type ApiResponse<T> = Result<T, ApiError>;
+
     pub fn create_order(amount: u8, name: String) -> Result<Order, InvalidOrder> {
         if amount <= 0 {
             unimplemented!()
@@ -33,12 +35,12 @@ mod lib {
         unimplemented!()
     }

-    pub fn authorize(auth_token: String) -> Result<SessionToken, ApiError> {
+    pub fn authorize(auth_token: String) -> ApiResponse<SessionToken> {
         unimplemented!()
     }

     pub fn send_order(session_token: &SessionToken,
-                      order: Order) -> Result<OrderResponse, ApiError> {
+                      order: Order) -> ApiResponse<OrderResponse> {
         unimplemented!()
     }
 }

Aqui temos o codigo com o resultado final, pronto para ser compilado

Conclusão

Depois de todos esses passos, o domínio no nosso programa está bem mais definido do que no início, mas com mais linhas de código também.

Trocamos verbosidade e tamanho de código por um programa expressando melhor nosso domínio.

Começamos com um programa bem simples que resolveu nosso problema e evoluímos aos poucos para trazer algumas das suposições e expectativas que guardavamos em nossa cabeça como algo verificável pelo compilador.

Poucas das vezes tivemos que alterar o código no main. As alterações necessárias aconteceram para definir estratégias que antes estava definidas implicitamente e que por padrão seria abortar o programa inteiro com um erro.

Algumas categorias de erro em runtime foram removidos, como null pointer exception ou undefined is not a function.

Foi possível criar um relacionamento mais claro entre as saídas e entradas das funções, tornando mais fácil navegar pelo módulo e definir a ordem das chamadas de métodos.

Mesmo sem escrever a implementação dos nosso metódos, podemos extrair algumas informações sobre nosso domínio. Saber extrair e definir essas informações e intenções também é uma prática a ser melhor explorada pelos desenvolvedores.

É preciso conhecer a semântica e regras do sistema para poder extrair e descrever melhor a intenção do código. Essa é uma habilidade a ser desenvolvida, assim como a habilidade de interpretação de texto.

Uma apresentação que trabalha a idea de limitar os estados impossíveis do domínio através do código é a “Making Impossible States Impossible” pelo Richard Feldman, com exemplos em Elm. Recomendo assistir também, mesmo em outra linguagem, no intuito de focar no conceito.

Esse resultado final não está tão idiomático e pode melhorar. Mas já temos o suficiente para explorar a expressividade de um sistema de tipos estáticos como o de Rust para o dominio através de código.

Como não cheguei a implementar o corpo das funções e quis apenas focar na informação que a assinatura contém, não cheguei a explorar como TDD pode nos ajudar a evoluir nosso design em conjunto dos tipos. Isso pode ser material para outro post.

Espero que você tenha gostado do texto e que consiga explorar esta ideia nas suas implementações futuras. Me enviem um post-resposta para discutirmos mais sobre este tema! (Mesmo em outro idioma :)