trubidata
Lección 14 de 14·25 min

Proyecto final: sistema bancario

En esta guía (11)

En esta lección vas a recorrer el proyecto final del curso: un sistema bancario chico pero completo, escrito en Scala 3 y organizado como un proyecto sbt. Integra casi todo lo que viste: clases y herencia, companion objects, colecciones inmutables, excepciones, pattern matching y funciones de nivel superior con @main. Vas a ver cómo está modelado, cómo correrlo, qué partes se pueden mejorar y por qué, y vas a extenderlo con ejercicios.

Descargá el proyecto (sistema-bancario.zip)

Ver el repo en GitHub

El objetivo

El sistema tiene que permitir:

  • Registrar clientes en un banco.
  • Abrirle a cada cliente cuentas de distintos tipos: caja de ahorro, cuenta corriente (con sobregiro) y cuenta de inversión (con plazo).
  • Hacer depósitos, retiros y transferencias, validando los montos y los fondos.
  • Generar intereses en las cuentas de inversión.
  • Registrar cada operación exitosa como una transacción, y exportarlas para auditoría.

Es la continuación directa de los ejercicios de las lecciones 8 y 9: las mismas clases, ya organizadas en un proyecto real.

Estructura del proyecto

text
sistema-bancario/
├── build.sbt
├── project/
│   └── build.properties
└── src/
    └── main/
        └── scala/
            ├── banco/
            │   ├── Banco.scala
            │   ├── Cliente.scala
            │   ├── Cuenta.scala
            │   └── Transaccion.scala
            ├── main.scala
            ├── clientes.txt
            ├── cuentas.csv
            └── archivo.txt

El build.sbt es el mínimo indispensable, con las versiones del curso:

scala
// build.sbt (lo lee sbt; no se compila como código de la aplicación)
ThisBuild / version := "0.1.0-SNAPSHOT"

ThisBuild / scalaVersion := "3.3.1"

lazy val root = (project in file("."))
  .settings(
    name := "SistemaBancario"
  )

Y project/build.properties fija sbt.version = 1.9.6.

  • Las clases del dominio están en el paquete banco: cada archivo arranca con package banco, y main.scala las trae con import banco._.
  • clientes.txt, cuentas.csv y archivo.txt son los archivos de datos que usaste en los ejercicios de la lección 11. El programa principal no los usa todavía: leerlos es uno de los ejercicios de esta lección.

Cómo correrlo

  1. Descargá y descomprimí el proyecto.
  2. Abrí una terminal en la carpeta sistema-bancario.
  3. Ejecutá:
bash
sbt run

La primera vez, sbt descarga Scala 3.3.1 y sus dependencias, así que tarda un poco. Como el proyecto tiene una sola función marcada con @main, sbt la ejecuta directamente. Si lo abrís con IntelliJ IDEA (plugin Scala) o VS Code (Metals), el editor reconoce el proyecto sbt y te deja ejecutarlo desde ahí.

El modelo: Cuenta.scala

Las cuentas son una jerarquía de clases: una Cuenta base y tres subclases.

scala
// src/main/scala/banco/Cuenta.scala
package banco

class Cuenta(saldoInicial: Double) {

  protected val tipo = "Cuenta"

  protected var saldo = saldoInicial
  val numeroCuenta: String = java.util.UUID.randomUUID.toString

  def getSaldo: Double = saldo

  def getTipo: String = tipo

  def depositar(monto: Double): Unit = {
    saldo += monto
  }

  def retirar(monto: Double): Unit = {
    if (monto <= saldo) {
      saldo -= monto
    } else {
      throw new Exception("Fondos insuficientes.")
    }
  }

  override def toString: String = {
    s"$tipo | ID: $numeroCuenta Saldo: $saldo"
  }
}
  • El saldo es un protected var: las subclases lo pueden modificar, pero desde afuera solo se lee con getSaldo. Es el encapsulamiento de la lección 8.
  • Cada cuenta recibe un número único generado con java.util.UUID, una clase de Java usada directamente desde Scala.
  • retirar lanza una excepción si no hay fondos. Así, quien llama se entera del error y puede decidir qué hacer, en lugar de que la cuenta imprima un mensaje y siga como si nada.
  • tipo es un protected val que cada subclase sobrescribe (lección 9) para identificarse.

Las subclases especializan la cuenta:

scala
// continúa del bloque anterior (mismo archivo Cuenta.scala)
class CuentaAhorro(saldoInicial: Double, tasaInteres: Double) extends Cuenta(saldoInicial) {
  override protected val tipo = "CuentaAhorro"
}

class CuentaCorriente(saldoInicial: Double, sobreGiro: Double) extends Cuenta(saldoInicial) {

  override protected val tipo = "CuentaCorriente"

  override def retirar(monto: Double): Unit = {
    if (monto <= saldo + sobreGiro) {
      saldo -= monto
    } else {
      throw new Exception("Fondos insuficientes.")
    }
  }
}

class CuentaInversion(saldoInicial: Double, plazoAnios: Int) extends Cuenta(saldoInicial) {

  override protected val tipo = "CuentaInversion"

  def calcularIntereses(tasaInteresAnual: Double): Double = {
    saldo * tasaInteresAnual / 100 * plazoAnios
  }
}
  • CuentaCorriente sobrescribe retirar para permitir quedar en negativo hasta el límite del sobregiro.
  • CuentaInversion agrega un método propio, calcularIntereses: interés simple sobre el saldo, según la tasa anual y el plazo en años.
  • CuentaAhorro recibe una tasaInteres pero todavía no la usa: es otro de los ejercicios.

Cliente.scala

scala
// src/main/scala/banco/Cliente.scala
package banco

class Cliente(nombre: String, direccion: String) {
  private val id = Cliente.siguienteID()
  private var cuentas: List[Cuenta] = List()

  def agregarCuenta(cuenta: Cuenta): Cuenta = {
    cuentas = cuenta :: cuentas
    cuenta
  }

  def consultarSaldoTotal(): Double = {
    cuentas.map(_.getSaldo).sum
  }

  def getCuenta(tipo: String): Cuenta = {
    val tipoCuenta = tipo match {
      case "CA" => "CuentaAhorro"
      case "CC" => "CuentaCorriente"
      case "CI" => "CuentaInversion"
    }
    cuentas.filter(_.getTipo == tipoCuenta).head
  }

  override def toString: String = {
    val imprimirCuentas = cuentas.map(c => c.toString).mkString("\n")
    s"Cliente ============================\nID: $id Nombre: $nombre SaldoTotal: ${consultarSaldoTotal()}\n\nCuentas: \n${imprimirCuentas}\n===================================="
  }
}

object Cliente {
  var idClientes = 0

  def siguienteID(): Int = {
    idClientes += 1
    idClientes
  }
}
  • El companion object Cliente lleva el contador de IDs, igual que en la lección 8: cada cliente nuevo recibe el siguiente número.
  • Las cuentas se guardan en una List inmutable dentro de un private var: para agregar una, se crea una lista nueva con :: (que agrega al principio) y se reasigna.
  • consultarSaldoTotal es un map más un sum, como en la lección 7.
  • getCuenta traduce la abreviatura ("CA", "CC", "CI") al tipo con un match, y busca la primera cuenta de ese tipo.

Transaccion.scala y Banco.scala

scala
// src/main/scala/banco/Transaccion.scala
package banco

class Transaccion(origen: Cuenta, destino: Cuenta, monto: Double, fecha: String, tipo: String) {
  // tipo puede ser: transferencia (Cuenta a Cuenta), deposito (Nada a Cuenta), retiro (Cuenta a Nada)

  override def toString: String = {
    s"Transaccion  | CuentaOrigen: $origen CuentaDestino: $destino Monto: $monto Fecha: $fecha Tipo: $tipo"
  }
}
scala
// src/main/scala/banco/Banco.scala
package banco
import java.time.LocalDateTime

object Banco {
  private var clientes: List[Cliente] = List()
  private var transacciones: List[Transaccion] = List()

  def agregarCliente(cliente: Cliente): Cliente = {
    println("Cliente agregado!")
    clientes = cliente :: clientes
    cliente
  }

  def guardarTransaccion(origen: Cuenta, destino: Cuenta, monto: Double, tipo: String): Unit = {
    if (monto > 0) {
      val fecha = LocalDateTime.now.toString
      val transaccion = new Transaccion(origen, destino, monto, fecha, tipo)
      transacciones = transaccion :: transacciones
    }
  }

  def exportarTransacciones(): List[Transaccion] = {
    println("Exportando transacciones...")
    transacciones.foreach(println)
    transacciones
  }
}
  • Banco es un objeto singleton: hay un único banco en todo el programa, accesible desde cualquier lado como Banco.
  • Guarda clientes y transacciones en listas privadas; solo se modifican a través de sus métodos.
  • Cada transacción registra su fecha con LocalDateTime.now.

El programa: main.scala

main.scala define una función por operación y una función @main que arma el escenario. Todas las operaciones siguen el mismo esquema: validar el monto, intentar la operación dentro de un try, y registrar la transacción solo si salió bien.

scala
// src/main/scala/main.scala (fragmento)
import banco._

def realizarTransferencia(cuentaOrigen: Cuenta, cuentaDestino: Cuenta, monto: Double): Unit = {
  if (monto > 0) {
    try {
      cuentaOrigen.retirar(monto)
      cuentaDestino.depositar(monto)
      println(s"Transferencia de $monto realizada con exito")
      Banco.guardarTransaccion(cuentaOrigen, cuentaDestino, monto, "Transferencia")
    } catch {
      case e: Exception => println(e.getMessage)
    }
  } else {
    println("El monto debe ser mayor a 0")
  }
}

def generarIntereses(cuenta: Cuenta, tasa: Double): Unit = {
  if (cuenta.getTipo == "CuentaInversion") {
    try {
      val intereses = cuenta.asInstanceOf[CuentaInversion].calcularIntereses(tasa)
      cuenta.depositar(intereses)
      println("Intereses generados con exito")
      Banco.guardarTransaccion(null, cuenta, intereses, "Intereses")
    } catch {
      case e: Exception => println(e.getMessage)
    }
  } else {
    println("La cuenta no es de inversion")
  }
}

realizarDeposito y realizarRetiro tienen la misma forma. Fijate en el orden dentro de realizarTransferencia: primero retirar, que es lo que puede fallar. Si lanza la excepción, el depositar nunca se ejecuta y no se registra nada, así que no hay dinero que aparezca de la nada.

En Scala 3 las funciones pueden estar en el nivel superior de un archivo, sin un object alrededor, y @main marca el punto de entrada:

scala
// continúa del bloque anterior (main.scala, versión resumida)
@main
def main(): Unit = {
  val cl1 = Banco.agregarCliente(new Cliente("Lucas", "123"))
  cl1.agregarCuenta(new CuentaAhorro(100, 5))
  cl1.agregarCuenta(new CuentaCorriente(200, 80))

  val cl2 = Banco.agregarCliente(new Cliente("Martina", "321"))
  cl2.agregarCuenta(new CuentaAhorro(200, 5))
  cl2.agregarCuenta(new CuentaCorriente(400, 100))
  cl2.agregarCuenta(new CuentaInversion(500, 1))

  // Cliente 1 hace un retiro de su CC de 250
  realizarRetiro(cl1.getCuenta("CC"), 250)
  // Cliente 1 trata de hacer un retiro de su CC de 1000 (debe fallar)
  realizarRetiro(cl1.getCuenta("CC"), 1000)
  // Cliente 1 hace una transferencia al Cliente 2, de su CC a la CA de 50
  realizarTransferencia(cl1.getCuenta("CC"), cl2.getCuenta("CA"), 50)
  // Cliente 2 genera intereses del 5% en su CI
  generarIntereses(cl2.getCuenta("CI"), 5)

  Banco.exportarTransacciones()
}

El main completo del proyecto hace nueve operaciones y muestra al cliente después de cada una. Este es el recorrido, con el saldo de cada cuenta:

OperaciónResultadoSaldos después
Lucas retira 250 de su CCOK, usa el sobregiroCC: -50
Lucas deposita 50 en su CAOKCA: 150
Lucas retira 1000 de su CCFalla: supera el sobregiro de 80CC: -50
Lucas transfiere 50 de su CA a su CCOKCA: 100, CC: 0
Lucas transfiere 500 de su CC a su CAFallasin cambios
Lucas transfiere 50 de su CC a la CA de MartinaOKCC de Lucas: -50, CA de Martina: 250
Martina retira 1000 de su CIFalla: la CI tiene 500CI: 500
Martina retira 100 de su CCOKCC: 300
Martina genera intereses del 5% en su CIOK: 500 * 5 / 100 * 1 = 25CI: 525

Al final, Lucas tiene un saldo total de 50 (100 + -50) y Martina de 1075 (250 + 300 + 525), y se exportan seis transacciones, las de las operaciones exitosas.

Qué se puede mejorar

El proyecto funciona, y está escrito con lo que se ve en el curso hasta las clases. Con todo lo demás que aprendiste, hay varias cosas que se pueden hacer más robustas. Ninguna rompe el escenario del main, pero todas pueden fallar con otros datos:

  1. getCuenta puede lanzar excepciones. Si le pasás un tipo que no es "CA", "CC" ni "CI", el match no tiene caso que coincida y lanza un MatchError. Si el cliente no tiene una cuenta de ese tipo, .head sobre una lista vacía lanza una NoSuchElementException. Lo más seguro es devolver un Option[Cuenta] (con find en lugar de filter(...).head) y que quien llama decida qué hacer si no está.
  2. Las transacciones usan null. Un depósito no tiene cuenta de origen y un retiro no tiene destino, y el código lo resuelve pasando null. Funciona porque nadie usa esos valores, pero es la puerta a una NullPointerException. En Scala se expresa con Option[Cuenta]: Some(cuenta) o None.
  3. El tipo de cuenta se compara como texto. cuenta.getTipo == "CuentaInversion" seguido de asInstanceOf[CuentaInversion] son dos pasos que dependen de que el string y la clase coincidan. Con match y un patrón de tipo (lección 9) se hace en un solo paso, sin posibilidad de error.
  4. throw new Exception(...) es demasiado genérico. Al atrapar Exception se atrapa cualquier cosa, incluso errores que no tienen que ver con los fondos. Lanzar una excepción específica, como IllegalArgumentException o una propia (class FondosInsuficientes(msg: String) extends Exception(msg)), permite manejar cada caso por separado.
  5. El contador de IDs es público. En el companion de Cliente, idClientes es un var público, así que cualquier parte del programa lo puede modificar. Debería ser private, como el contadorID de la lección 8.
  6. El comentario del último caso dice "CA", pero el código usa la CI. Las cajas de ahorro no generan intereses todavía: CuentaAhorro recibe tasaInteres pero no la guarda como atributo. Agregarlo es uno de los ejercicios.

Detalles menores: import banco.Cuenta en Cliente.scala sobra (las clases de un mismo paquete se ven sin importarlas), y el toString de Transaccion muestra la cuenta completa en lugar de solo su número.

Practicá

  1. Una búsqueda de cuentas segura. Cambiá getCuenta para que devuelva Option[Cuenta] y no pueda lanzar excepciones, y adaptá el uso en main.
  2. Transacciones sin null. Reescribí Transaccion como una case class con origen y destino de tipo Option[Cuenta], y que exportarTransacciones escriba un archivo transacciones.csv con el formato tipo,origen,destino,monto,fecha (usá el número de cuenta, o vacío si no hay).
  3. Intereses en la caja de ahorro. Hacé que CuentaAhorro guarde su tasaInteres y tenga un método calcularIntereses(). Reescribí generarIntereses con un match por tipo de cuenta, sin getTipo ni asInstanceOf.
  4. Cargar clientes desde archivo. Escribí una función que lea src/main/scala/clientes.txt (lección 11) y cree, para cada línea, un Cliente registrado en el banco con una cuenta de cada tipo indicado (CA, CC o CI), con saldo inicial 0.

Soluciones

scala
// continúa del proyecto. Ejercicio 1: reemplaza getCuenta en Cliente.scala
def getCuenta(tipo: String): Option[Cuenta] = {
  val tipoCuenta = tipo match {
    case "CA" => Some("CuentaAhorro")
    case "CC" => Some("CuentaCorriente")
    case "CI" => Some("CuentaInversion")
    case _    => None
  }
  tipoCuenta.flatMap(t => cuentas.find(_.getTipo == t))
}

// En main.scala: solo se opera si la cuenta existe
cl1.getCuenta("CC") match {
  case Some(cuenta) => realizarRetiro(cuenta, 250)
  case None         => println("El cliente no tiene cuenta corriente")
}

// O, para una transferencia, con dos cuentas que tienen que existir:
for {
  origen  <- cl1.getCuenta("CC")
  destino <- cl2.getCuenta("CA")
} realizarTransferencia(origen, destino, 50)

find devuelve la primera cuenta que cumple la condición como Option, y flatMap encadena las dos búsquedas: si el tipo es inválido, ni siquiera busca. El for sin yield ejecuta la transferencia solo si las dos cuentas existen. Adaptá igual el resto de las llamadas a getCuenta del main (depósitos, retiros e intereses): ahora devuelven Option, así que el código anterior deja de compilar hasta que las cambies. Usar .get lo hace compilar rápido, pero vuelve a lanzar una excepción si la cuenta no existe, que es justo lo que este ejercicio quiere evitar.

scala
// Ejercicio 2: reemplaza src/main/scala/banco/Transaccion.scala
package banco

case class Transaccion(
  origen: Option[Cuenta],
  destino: Option[Cuenta],
  monto: Double,
  fecha: String,
  tipo: String
) {
  def toCSV: String = {
    val o = origen.map(_.numeroCuenta).getOrElse("")
    val d = destino.map(_.numeroCuenta).getOrElse("")
    s"$tipo,$o,$d,$monto,$fecha"
  }
}
scala
// Ejercicio 2: reemplaza src/main/scala/banco/Banco.scala
package banco

import java.io.PrintWriter
import java.time.LocalDateTime
import scala.util.Using

object Banco {
  private var clientes: List[Cliente] = List()
  private var transacciones: List[Transaccion] = List()

  def agregarCliente(cliente: Cliente): Cliente = {
    clientes = cliente :: clientes
    cliente
  }

  def guardarTransaccion(origen: Option[Cuenta], destino: Option[Cuenta], monto: Double, tipo: String): Unit = {
    if (monto > 0) {
      transacciones = Transaccion(origen, destino, monto, LocalDateTime.now.toString, tipo) :: transacciones
    }
  }

  def exportarTransacciones(ruta: String = "transacciones.csv"): Unit =
    Using.resource(new PrintWriter(ruta, "UTF-8")) { writer =>
      writer.println("tipo,origen,destino,monto,fecha")
      transacciones.reverse.foreach(t => writer.println(t.toCSV)) // en orden cronológico
    }
}

// En main.scala, cada llamada pasa Some(...) o None. Por ejemplo, en realizarDeposito:
// Banco.guardarTransaccion(None, Some(cuenta), monto, "Deposito")

Las transacciones se van agregando al principio de la lista con ::, así que la más nueva queda primera; reverse las escribe en el orden en que ocurrieron.

scala
// continúa del proyecto. Ejercicio 3: reemplaza CuentaAhorro en Cuenta.scala
class CuentaAhorro(saldoInicial: Double, val tasaInteres: Double) extends Cuenta(saldoInicial) {
  override protected val tipo = "CuentaAhorro"

  def calcularIntereses(): Double = saldo * tasaInteres / 100
}

// En main.scala
def generarIntereses(cuenta: Cuenta, tasa: Double): Unit = {
  val intereses: Option[Double] = cuenta match {
    case ci: CuentaInversion => Some(ci.calcularIntereses(tasa))
    case ca: CuentaAhorro    => Some(ca.calcularIntereses())
    case _                   => None
  }

  intereses match {
    case Some(monto) if monto > 0 =>
      cuenta.depositar(monto)
      println(s"Intereses de $monto generados con exito")
      Banco.guardarTransaccion(None, Some(cuenta), monto, "Intereses")
    case Some(_) =>
      println("No hay intereses para generar")
    case None =>
      println("Esta cuenta no genera intereses")
  }
}

Esta solución usa la versión de guardarTransaccion con Option del ejercicio 2. El match por tipo reemplaza al par getTipo + asInstanceOf: si la cuenta es una CuentaInversion, la variable ci ya tiene ese tipo y se puede llamar a calcularIntereses sin conversión. En la caja de ahorro se usa la tasa propia de la cuenta (con la CA de Martina, 250 * 5 / 100 = 12.5).

scala
// continúa del proyecto. Ejercicio 4: agregar en main.scala
import scala.io.Source
import scala.util.Using

def cargarClientes(ruta: String): List[Cliente] =
  Using.resource(Source.fromFile(ruta, "UTF-8")) { archivo =>
    archivo.getLines().drop(1).filter(_.trim.nonEmpty).map { linea =>
      val Array(_, nombre, direccion, tipos) = linea.split('|')
      val cliente = Banco.agregarCliente(new Cliente(nombre, direccion))
      tipos.split(',').map(_.trim).foreach {
        case "CA" => cliente.agregarCuenta(new CuentaAhorro(0, 5))
        case "CC" => cliente.agregarCuenta(new CuentaCorriente(0, 100))
        case "CI" => cliente.agregarCuenta(new CuentaInversion(0, 1))
        case otro => println(s"Tipo de cuenta desconocido para $nombre: $otro")
      }
      cliente
    }.toList
  }

// Uso, dentro del @main:
// val cargados = cargarClientes("src/main/scala/clientes.txt")
// cargados.foreach(println)

El primer campo de cada línea (el id del archivo) se descarta con _, porque el sistema asigna los IDs con su propio contador. La ruta es relativa a la raíz del proyecto, que es desde donde ejecuta sbt run. Fijate el último case otro: sin él, un tipo desconocido en el archivo lanzaría un MatchError y cortaría la carga de todos los clientes.

Próximos pasos

Con este proyecto cerrás el recorrido por los fundamentos de Scala: el lenguaje, sus colecciones, la orientación a objetos con clases, herencia y traits, y el trabajo con texto y archivos. Algunos caminos para seguir:

  • Tests. Agregá una librería de tests como MUnit o ScalaTest al build.sbt y escribí pruebas para retirar, realizarTransferencia y generarIntereses. Los casos de "debe fallar" del main son tests esperando a ser escritos.
  • Más programación funcional. Reescribí el sistema con case classes inmutables: en lugar de modificar el saldo de una cuenta, cada operación devuelve una cuenta nueva, y los errores se devuelven como Either en lugar de excepciones.
  • Scala para datos. Scala es el lenguaje nativo de Apache Spark, así que todo lo que aprendiste es la base para escribir procesamiento de datos distribuido.

← Volver al temario