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)
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
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:
// 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 conpackage banco, ymain.scalalas trae conimport banco._. clientes.txt,cuentas.csvyarchivo.txtson 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
- Descargá y descomprimí el proyecto.
- Abrí una terminal en la carpeta
sistema-bancario. - Ejecutá:
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.
// 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 congetSaldo. 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. retirarlanza 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.tipoes unprotected valque cada subclase sobrescribe (lección 9) para identificarse.
Las subclases especializan la cuenta:
// 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
}
}
CuentaCorrientesobrescriberetirarpara permitir quedar en negativo hasta el límite del sobregiro.CuentaInversionagrega un método propio,calcularIntereses: interés simple sobre el saldo, según la tasa anual y el plazo en años.CuentaAhorrorecibe unatasaInterespero todavía no la usa: es otro de los ejercicios.
Cliente.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
Clientelleva 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
Listinmutable dentro de unprivate var: para agregar una, se crea una lista nueva con::(que agrega al principio) y se reasigna. consultarSaldoTotales unmapmás unsum, como en la lección 7.getCuentatraduce la abreviatura ("CA","CC","CI") al tipo con unmatch, y busca la primera cuenta de ese tipo.
Transaccion.scala y Banco.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"
}
}
// 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
}
}
Bancoes un objeto singleton: hay un único banco en todo el programa, accesible desde cualquier lado comoBanco.- 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.
// 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:
// 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ón | Resultado | Saldos después |
|---|---|---|
| Lucas retira 250 de su CC | OK, usa el sobregiro | CC: -50 |
| Lucas deposita 50 en su CA | OK | CA: 150 |
| Lucas retira 1000 de su CC | Falla: supera el sobregiro de 80 | CC: -50 |
| Lucas transfiere 50 de su CA a su CC | OK | CA: 100, CC: 0 |
| Lucas transfiere 500 de su CC a su CA | Falla | sin cambios |
| Lucas transfiere 50 de su CC a la CA de Martina | OK | CC de Lucas: -50, CA de Martina: 250 |
| Martina retira 1000 de su CI | Falla: la CI tiene 500 | CI: 500 |
| Martina retira 100 de su CC | OK | CC: 300 |
| Martina genera intereses del 5% en su CI | OK: 500 * 5 / 100 * 1 = 25 | CI: 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:
getCuentapuede lanzar excepciones. Si le pasás un tipo que no es"CA","CC"ni"CI", elmatchno tiene caso que coincida y lanza unMatchError. Si el cliente no tiene una cuenta de ese tipo,.headsobre una lista vacía lanza unaNoSuchElementException. Lo más seguro es devolver unOption[Cuenta](confinden lugar defilter(...).head) y que quien llama decida qué hacer si no está.- Las transacciones usan
null. Un depósito no tiene cuenta de origen y un retiro no tiene destino, y el código lo resuelve pasandonull. Funciona porque nadie usa esos valores, pero es la puerta a unaNullPointerException. En Scala se expresa conOption[Cuenta]:Some(cuenta)oNone. - El tipo de cuenta se compara como texto.
cuenta.getTipo == "CuentaInversion"seguido deasInstanceOf[CuentaInversion]son dos pasos que dependen de que el string y la clase coincidan. Conmatchy un patrón de tipo (lección 9) se hace en un solo paso, sin posibilidad de error. throw new Exception(...)es demasiado genérico. Al atraparExceptionse atrapa cualquier cosa, incluso errores que no tienen que ver con los fondos. Lanzar una excepción específica, comoIllegalArgumentExceptiono una propia (class FondosInsuficientes(msg: String) extends Exception(msg)), permite manejar cada caso por separado.- El contador de IDs es público. En el companion de
Cliente,idClienteses unvarpúblico, así que cualquier parte del programa lo puede modificar. Debería serprivate, como elcontadorIDde la lección 8. - El comentario del último caso dice "CA", pero el código usa la CI. Las cajas de ahorro no generan intereses todavía:
CuentaAhorrorecibetasaInterespero 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á
- Una búsqueda de cuentas segura. Cambiá
getCuentapara que devuelvaOption[Cuenta]y no pueda lanzar excepciones, y adaptá el uso enmain. - Transacciones sin null. Reescribí
Transaccioncomo una case class conorigenydestinode tipoOption[Cuenta], y queexportarTransaccionesescriba un archivotransacciones.csvcon el formatotipo,origen,destino,monto,fecha(usá el número de cuenta, o vacío si no hay). - Intereses en la caja de ahorro. Hacé que
CuentaAhorroguarde sutasaInteresy tenga un métodocalcularIntereses(). ReescribígenerarInteresescon unmatchpor tipo de cuenta, singetTiponiasInstanceOf. - Cargar clientes desde archivo. Escribí una función que lea
src/main/scala/clientes.txt(lección 11) y cree, para cada línea, unClienteregistrado en el banco con una cuenta de cada tipo indicado (CA,CCoCI), con saldo inicial 0.
Soluciones
// 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.
// 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"
}
}
// 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.
// 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).
// 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.sbty escribí pruebas pararetirar,realizarTransferenciaygenerarIntereses. Los casos de "debe fallar" delmainson 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
Eitheren 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.